load_test.go

   1package config
   2
   3import (
   4	"io"
   5	"log/slog"
   6	"os"
   7	"path/filepath"
   8	"testing"
   9
  10	"charm.land/catwalk/pkg/catwalk"
  11	"github.com/charmbracelet/crush/internal/csync"
  12	"github.com/charmbracelet/crush/internal/env"
  13	"github.com/stretchr/testify/assert"
  14	"github.com/stretchr/testify/require"
  15)
  16
  17func TestMain(m *testing.M) {
  18	slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
  19
  20	exitVal := m.Run()
  21	os.Exit(exitVal)
  22}
  23
  24func TestConfig_LoadFromBytes(t *testing.T) {
  25	data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
  26	data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
  27	data3 := []byte(`{"providers": {"openai": {}}}`)
  28
  29	loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
  30
  31	require.NoError(t, err)
  32	require.NotNil(t, loadedConfig)
  33	require.Equal(t, 1, loadedConfig.Providers.Len())
  34	pc, _ := loadedConfig.Providers.Get("openai")
  35	require.Equal(t, "key2", pc.APIKey)
  36	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
  37}
  38
  39func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
  40	t.Parallel()
  41
  42	t.Run("identifies the offending file", func(t *testing.T) {
  43		t.Parallel()
  44		tmpDir := t.TempDir()
  45		good := filepath.Join(tmpDir, "good.json")
  46		bad := filepath.Join(tmpDir, "bad.json")
  47		require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
  48		require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
  49
  50		_, _, err := loadFromConfigPaths([]string{good, bad})
  51		require.Error(t, err)
  52		require.Contains(t, err.Error(), "invalid JSON in config file")
  53		require.Contains(t, err.Error(), "bad.json")
  54	})
  55
  56	t.Run("skips missing and empty files", func(t *testing.T) {
  57		t.Parallel()
  58		tmpDir := t.TempDir()
  59		empty := filepath.Join(tmpDir, "empty.json")
  60		require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
  61
  62		cfg, _, err := loadFromConfigPaths([]string{
  63			filepath.Join(tmpDir, "nonexistent.json"),
  64			empty,
  65		})
  66		require.NoError(t, err)
  67		require.NotNil(t, cfg)
  68	})
  69}
  70
  71// testStore wraps a Config in a minimal ConfigStore for testing.
  72func testStore(cfg *Config) *ConfigStore {
  73	return &ConfigStore{config: cfg}
  74}
  75
  76func TestConfig_setDefaults(t *testing.T) {
  77	cfg := &Config{}
  78
  79	cfg.setDefaults("/tmp", "")
  80
  81	require.NotNil(t, cfg.Options)
  82	require.NotNil(t, cfg.Options.TUI)
  83	require.NotNil(t, cfg.Options.ContextPaths)
  84	require.NotNil(t, cfg.Providers)
  85	require.NotNil(t, cfg.Models)
  86	require.NotNil(t, cfg.LSP)
  87	require.NotNil(t, cfg.MCP)
  88	require.Equal(t, filepath.Join("/tmp", ".crush"), cfg.Options.DataDirectory)
  89	require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
  90	for _, path := range defaultContextPaths {
  91		require.Contains(t, cfg.Options.ContextPaths, path)
  92	}
  93}
  94
  95func TestConfig_configureProviders(t *testing.T) {
  96	knownProviders := []catwalk.Provider{
  97		{
  98			ID:          "openai",
  99			APIKey:      "$OPENAI_API_KEY",
 100			APIEndpoint: "https://api.openai.com/v1",
 101			Models: []catwalk.Model{{
 102				ID: "test-model",
 103			}},
 104		},
 105	}
 106
 107	cfg := &Config{}
 108	cfg.setDefaults("/tmp", "")
 109	env := env.NewFromMap(map[string]string{
 110		"OPENAI_API_KEY": "test-key",
 111	})
 112	resolver := NewShellVariableResolver(env)
 113	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 114	require.NoError(t, err)
 115	require.Equal(t, 1, cfg.Providers.Len())
 116
 117	// We want to make sure that we keep the configured API key as a placeholder
 118	pc, _ := cfg.Providers.Get("openai")
 119	require.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
 120}
 121
 122func TestConfig_configureProvidersWithOverride(t *testing.T) {
 123	knownProviders := []catwalk.Provider{
 124		{
 125			ID:          "openai",
 126			APIKey:      "$OPENAI_API_KEY",
 127			APIEndpoint: "https://api.openai.com/v1",
 128			Models: []catwalk.Model{{
 129				ID: "test-model",
 130			}},
 131		},
 132	}
 133
 134	cfg := &Config{
 135		Providers: csync.NewMap[string, ProviderConfig](),
 136	}
 137	cfg.Providers.Set("openai", ProviderConfig{
 138		APIKey:  "xyz",
 139		BaseURL: "https://api.openai.com/v2",
 140		Models: []catwalk.Model{
 141			{
 142				ID:   "test-model",
 143				Name: "Updated",
 144			},
 145			{
 146				ID: "another-model",
 147			},
 148		},
 149	})
 150	cfg.setDefaults("/tmp", "")
 151
 152	env := env.NewFromMap(map[string]string{
 153		"OPENAI_API_KEY": "test-key",
 154	})
 155	resolver := NewShellVariableResolver(env)
 156	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 157	require.NoError(t, err)
 158	require.Equal(t, 1, cfg.Providers.Len())
 159
 160	// We want to make sure that we keep the configured API key as a placeholder
 161	pc, _ := cfg.Providers.Get("openai")
 162	require.Equal(t, "xyz", pc.APIKey)
 163	require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
 164	require.Len(t, pc.Models, 2)
 165	require.Equal(t, "Updated", pc.Models[0].Name)
 166}
 167
 168func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
 169	knownProviders := []catwalk.Provider{
 170		{
 171			ID:          "openai",
 172			APIKey:      "$OPENAI_API_KEY",
 173			APIEndpoint: "https://api.openai.com/v1",
 174			Models: []catwalk.Model{{
 175				ID: "test-model",
 176			}},
 177		},
 178	}
 179
 180	cfg := &Config{
 181		Providers: csync.NewMapFrom(map[string]ProviderConfig{
 182			"custom": {
 183				APIKey:  "xyz",
 184				BaseURL: "https://api.someendpoint.com/v2",
 185				Models: []catwalk.Model{
 186					{
 187						ID: "test-model",
 188					},
 189				},
 190			},
 191		}),
 192	}
 193	cfg.setDefaults("/tmp", "")
 194	env := env.NewFromMap(map[string]string{
 195		"OPENAI_API_KEY": "test-key",
 196	})
 197	resolver := NewShellVariableResolver(env)
 198	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 199	require.NoError(t, err)
 200	// Should be to because of the env variable
 201	require.Equal(t, cfg.Providers.Len(), 2)
 202
 203	// We want to make sure that we keep the configured API key as a placeholder
 204	pc, _ := cfg.Providers.Get("custom")
 205	require.Equal(t, "xyz", pc.APIKey)
 206	// Make sure we set the ID correctly
 207	require.Equal(t, "custom", pc.ID)
 208	require.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
 209	require.Len(t, pc.Models, 1)
 210
 211	_, ok := cfg.Providers.Get("openai")
 212	require.True(t, ok, "OpenAI provider should still be present")
 213}
 214
 215func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
 216	knownProviders := []catwalk.Provider{
 217		{
 218			ID:          catwalk.InferenceProviderBedrock,
 219			APIKey:      "",
 220			APIEndpoint: "",
 221			Models: []catwalk.Model{{
 222				ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 223			}},
 224		},
 225	}
 226
 227	cfg := &Config{}
 228	cfg.setDefaults("/tmp", "")
 229	env := env.NewFromMap(map[string]string{
 230		"AWS_ACCESS_KEY_ID":     "test-key-id",
 231		"AWS_SECRET_ACCESS_KEY": "test-secret-key",
 232	})
 233	resolver := NewShellVariableResolver(env)
 234	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 235	require.NoError(t, err)
 236	require.Equal(t, cfg.Providers.Len(), 1)
 237
 238	bedrockProvider, ok := cfg.Providers.Get("bedrock")
 239	require.True(t, ok, "Bedrock provider should be present")
 240	require.Len(t, bedrockProvider.Models, 1)
 241	require.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
 242}
 243
 244func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
 245	knownProviders := []catwalk.Provider{
 246		{
 247			ID:          catwalk.InferenceProviderBedrock,
 248			APIKey:      "",
 249			APIEndpoint: "",
 250			Models: []catwalk.Model{{
 251				ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 252			}},
 253		},
 254	}
 255
 256	cfg := &Config{}
 257	cfg.setDefaults("/tmp", "")
 258	env := env.NewFromMap(map[string]string{})
 259	resolver := NewShellVariableResolver(env)
 260	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 261	require.NoError(t, err)
 262	// Provider should not be configured without credentials
 263	require.Equal(t, cfg.Providers.Len(), 0)
 264}
 265
 266func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
 267	knownProviders := []catwalk.Provider{
 268		{
 269			ID:          catwalk.InferenceProviderBedrock,
 270			APIKey:      "",
 271			APIEndpoint: "",
 272			Models: []catwalk.Model{{
 273				ID: "some-random-model",
 274			}},
 275		},
 276	}
 277
 278	cfg := &Config{}
 279	cfg.setDefaults("/tmp", "")
 280	env := env.NewFromMap(map[string]string{
 281		"AWS_ACCESS_KEY_ID":     "test-key-id",
 282		"AWS_SECRET_ACCESS_KEY": "test-secret-key",
 283	})
 284	resolver := NewShellVariableResolver(env)
 285	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 286	require.Error(t, err)
 287}
 288
 289func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
 290	knownProviders := []catwalk.Provider{
 291		{
 292			ID:          catwalk.InferenceProviderVertexAI,
 293			APIKey:      "",
 294			APIEndpoint: "",
 295			Models: []catwalk.Model{{
 296				ID: "gemini-pro",
 297			}},
 298		},
 299	}
 300
 301	cfg := &Config{}
 302	cfg.setDefaults("/tmp", "")
 303	env := env.NewFromMap(map[string]string{
 304		"VERTEXAI_PROJECT":  "test-project",
 305		"VERTEXAI_LOCATION": "us-central1",
 306	})
 307	resolver := NewShellVariableResolver(env)
 308	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 309	require.NoError(t, err)
 310	require.Equal(t, cfg.Providers.Len(), 1)
 311
 312	vertexProvider, ok := cfg.Providers.Get("vertexai")
 313	require.True(t, ok, "VertexAI provider should be present")
 314	require.Len(t, vertexProvider.Models, 1)
 315	require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
 316	require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
 317	require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
 318}
 319
 320func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
 321	knownProviders := []catwalk.Provider{
 322		{
 323			ID:          catwalk.InferenceProviderVertexAI,
 324			APIKey:      "",
 325			APIEndpoint: "",
 326			Models: []catwalk.Model{{
 327				ID: "gemini-pro",
 328			}},
 329		},
 330	}
 331
 332	cfg := &Config{}
 333	cfg.setDefaults("/tmp", "")
 334	env := env.NewFromMap(map[string]string{
 335		"GOOGLE_GENAI_USE_VERTEXAI": "false",
 336		"GOOGLE_CLOUD_PROJECT":      "test-project",
 337		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 338	})
 339	resolver := NewShellVariableResolver(env)
 340	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 341	require.NoError(t, err)
 342	// Provider should not be configured without proper credentials
 343	require.Equal(t, cfg.Providers.Len(), 0)
 344}
 345
 346func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
 347	knownProviders := []catwalk.Provider{
 348		{
 349			ID:          catwalk.InferenceProviderVertexAI,
 350			APIKey:      "",
 351			APIEndpoint: "",
 352			Models: []catwalk.Model{{
 353				ID: "gemini-pro",
 354			}},
 355		},
 356	}
 357
 358	cfg := &Config{}
 359	cfg.setDefaults("/tmp", "")
 360	env := env.NewFromMap(map[string]string{
 361		"GOOGLE_GENAI_USE_VERTEXAI": "true",
 362		"GOOGLE_CLOUD_LOCATION":     "us-central1",
 363	})
 364	resolver := NewShellVariableResolver(env)
 365	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 366	require.NoError(t, err)
 367	// Provider should not be configured without project
 368	require.Equal(t, cfg.Providers.Len(), 0)
 369}
 370
 371func TestConfig_configureProvidersSetProviderID(t *testing.T) {
 372	knownProviders := []catwalk.Provider{
 373		{
 374			ID:          "openai",
 375			APIKey:      "$OPENAI_API_KEY",
 376			APIEndpoint: "https://api.openai.com/v1",
 377			Models: []catwalk.Model{{
 378				ID: "test-model",
 379			}},
 380		},
 381	}
 382
 383	cfg := &Config{}
 384	cfg.setDefaults("/tmp", "")
 385	env := env.NewFromMap(map[string]string{
 386		"OPENAI_API_KEY": "test-key",
 387	})
 388	resolver := NewShellVariableResolver(env)
 389	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 390	require.NoError(t, err)
 391	require.Equal(t, cfg.Providers.Len(), 1)
 392
 393	// Provider ID should be set
 394	pc, _ := cfg.Providers.Get("openai")
 395	require.Equal(t, "openai", pc.ID)
 396}
 397
 398func TestConfig_EnabledProviders(t *testing.T) {
 399	t.Run("all providers enabled", func(t *testing.T) {
 400		cfg := &Config{
 401			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 402				"openai": {
 403					ID:      "openai",
 404					APIKey:  "key1",
 405					Disable: false,
 406				},
 407				"anthropic": {
 408					ID:      "anthropic",
 409					APIKey:  "key2",
 410					Disable: false,
 411				},
 412			}),
 413		}
 414
 415		enabled := cfg.EnabledProviders()
 416		require.Len(t, enabled, 2)
 417	})
 418
 419	t.Run("some providers disabled", func(t *testing.T) {
 420		cfg := &Config{
 421			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 422				"openai": {
 423					ID:      "openai",
 424					APIKey:  "key1",
 425					Disable: false,
 426				},
 427				"anthropic": {
 428					ID:      "anthropic",
 429					APIKey:  "key2",
 430					Disable: true,
 431				},
 432			}),
 433		}
 434
 435		enabled := cfg.EnabledProviders()
 436		require.Len(t, enabled, 1)
 437		require.Equal(t, "openai", enabled[0].ID)
 438	})
 439
 440	t.Run("empty providers map", func(t *testing.T) {
 441		cfg := &Config{
 442			Providers: csync.NewMap[string, ProviderConfig](),
 443		}
 444
 445		enabled := cfg.EnabledProviders()
 446		require.Len(t, enabled, 0)
 447	})
 448}
 449
 450func TestConfig_IsConfigured(t *testing.T) {
 451	t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
 452		cfg := &Config{
 453			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 454				"openai": {
 455					ID:      "openai",
 456					APIKey:  "key1",
 457					Disable: false,
 458				},
 459			}),
 460		}
 461
 462		require.True(t, cfg.IsConfigured())
 463	})
 464
 465	t.Run("returns false when no providers are configured", func(t *testing.T) {
 466		cfg := &Config{
 467			Providers: csync.NewMap[string, ProviderConfig](),
 468		}
 469
 470		require.False(t, cfg.IsConfigured())
 471	})
 472
 473	t.Run("returns false when all providers are disabled", func(t *testing.T) {
 474		cfg := &Config{
 475			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 476				"openai": {
 477					ID:      "openai",
 478					APIKey:  "key1",
 479					Disable: true,
 480				},
 481				"anthropic": {
 482					ID:      "anthropic",
 483					APIKey:  "key2",
 484					Disable: true,
 485				},
 486			}),
 487		}
 488
 489		require.False(t, cfg.IsConfigured())
 490	})
 491}
 492
 493func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 494	cfg := &Config{
 495		Options: &Options{
 496			DisabledTools: []string{},
 497		},
 498	}
 499
 500	cfg.SetupAgents()
 501	coderAgent, ok := cfg.Agents[AgentCoder]
 502	require.True(t, ok)
 503	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 504
 505	taskAgent, ok := cfg.Agents[AgentTask]
 506	require.True(t, ok)
 507	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 508}
 509
 510func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 511	cfg := &Config{
 512		Options: &Options{
 513			DisabledTools: []string{
 514				"edit",
 515				"download",
 516				"grep",
 517			},
 518		},
 519	}
 520
 521	cfg.SetupAgents()
 522	coderAgent, ok := cfg.Agents[AgentCoder]
 523	require.True(t, ok)
 524
 525	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 526
 527	taskAgent, ok := cfg.Agents[AgentTask]
 528	require.True(t, ok)
 529	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 530}
 531
 532func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 533	cfg := &Config{
 534		Options: &Options{
 535			DisabledTools: []string{
 536				"glob",
 537				"grep",
 538				"ls",
 539				"sourcegraph",
 540				"view",
 541			},
 542		},
 543	}
 544
 545	cfg.SetupAgents()
 546	coderAgent, ok := cfg.Agents[AgentCoder]
 547	require.True(t, ok)
 548	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 549
 550	taskAgent, ok := cfg.Agents[AgentTask]
 551	require.True(t, ok)
 552	assert.Len(t, taskAgent.AllowedTools, 0)
 553}
 554
 555func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 556	knownProviders := []catwalk.Provider{
 557		{
 558			ID:          "openai",
 559			APIKey:      "$OPENAI_API_KEY",
 560			APIEndpoint: "https://api.openai.com/v1",
 561			Models: []catwalk.Model{{
 562				ID: "test-model",
 563			}},
 564		},
 565	}
 566
 567	cfg := &Config{
 568		Providers: csync.NewMapFrom(map[string]ProviderConfig{
 569			"openai": {
 570				Disable: true,
 571			},
 572		}),
 573	}
 574	cfg.setDefaults("/tmp", "")
 575
 576	env := env.NewFromMap(map[string]string{
 577		"OPENAI_API_KEY": "test-key",
 578	})
 579	resolver := NewShellVariableResolver(env)
 580	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 581	require.NoError(t, err)
 582
 583	require.Equal(t, cfg.Providers.Len(), 1)
 584	prov, exists := cfg.Providers.Get("openai")
 585	require.True(t, exists)
 586	require.True(t, prov.Disable)
 587}
 588
 589func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
 590	t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
 591		cfg := &Config{
 592			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 593				"custom": {
 594					BaseURL: "https://api.custom.com/v1",
 595					Models: []catwalk.Model{{
 596						ID: "test-model",
 597					}},
 598				},
 599				"openai": {
 600					APIKey: "$MISSING",
 601				},
 602			}),
 603		}
 604		cfg.setDefaults("/tmp", "")
 605
 606		env := env.NewFromMap(map[string]string{})
 607		resolver := NewShellVariableResolver(env)
 608		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 609		require.NoError(t, err)
 610
 611		require.Equal(t, cfg.Providers.Len(), 1)
 612		_, exists := cfg.Providers.Get("custom")
 613		require.True(t, exists)
 614	})
 615
 616	t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
 617		cfg := &Config{
 618			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 619				"custom": {
 620					APIKey: "test-key",
 621					Models: []catwalk.Model{{
 622						ID: "test-model",
 623					}},
 624				},
 625			}),
 626		}
 627		cfg.setDefaults("/tmp", "")
 628
 629		env := env.NewFromMap(map[string]string{})
 630		resolver := NewShellVariableResolver(env)
 631		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 632		require.NoError(t, err)
 633
 634		require.Equal(t, cfg.Providers.Len(), 0)
 635		_, exists := cfg.Providers.Get("custom")
 636		require.False(t, exists)
 637	})
 638
 639	t.Run("custom provider with no models is removed", func(t *testing.T) {
 640		cfg := &Config{
 641			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 642				"custom": {
 643					APIKey:  "test-key",
 644					BaseURL: "https://api.custom.com/v1",
 645					Models:  []catwalk.Model{},
 646				},
 647			}),
 648		}
 649		cfg.setDefaults("/tmp", "")
 650
 651		env := env.NewFromMap(map[string]string{})
 652		resolver := NewShellVariableResolver(env)
 653		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 654		require.NoError(t, err)
 655
 656		require.Equal(t, cfg.Providers.Len(), 0)
 657		_, exists := cfg.Providers.Get("custom")
 658		require.False(t, exists)
 659	})
 660
 661	t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
 662		cfg := &Config{
 663			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 664				"custom": {
 665					APIKey:  "test-key",
 666					BaseURL: "https://api.custom.com/v1",
 667					Type:    "unsupported",
 668					Models: []catwalk.Model{{
 669						ID: "test-model",
 670					}},
 671				},
 672			}),
 673		}
 674		cfg.setDefaults("/tmp", "")
 675
 676		env := env.NewFromMap(map[string]string{})
 677		resolver := NewShellVariableResolver(env)
 678		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 679		require.NoError(t, err)
 680
 681		require.Equal(t, cfg.Providers.Len(), 0)
 682		_, exists := cfg.Providers.Get("custom")
 683		require.False(t, exists)
 684	})
 685
 686	t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
 687		cfg := &Config{
 688			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 689				"custom": {
 690					APIKey:  "test-key",
 691					BaseURL: "https://api.custom.com/v1",
 692					Type:    catwalk.TypeOpenAI,
 693					Models: []catwalk.Model{{
 694						ID: "test-model",
 695					}},
 696				},
 697			}),
 698		}
 699		cfg.setDefaults("/tmp", "")
 700
 701		env := env.NewFromMap(map[string]string{})
 702		resolver := NewShellVariableResolver(env)
 703		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 704		require.NoError(t, err)
 705
 706		require.Equal(t, cfg.Providers.Len(), 1)
 707		customProvider, exists := cfg.Providers.Get("custom")
 708		require.True(t, exists)
 709		require.Equal(t, "custom", customProvider.ID)
 710		require.Equal(t, "test-key", customProvider.APIKey)
 711		require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
 712	})
 713
 714	t.Run("custom anthropic provider is supported", func(t *testing.T) {
 715		cfg := &Config{
 716			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 717				"custom-anthropic": {
 718					APIKey:  "test-key",
 719					BaseURL: "https://api.anthropic.com/v1",
 720					Type:    catwalk.TypeAnthropic,
 721					Models: []catwalk.Model{{
 722						ID: "claude-3-sonnet",
 723					}},
 724				},
 725			}),
 726		}
 727		cfg.setDefaults("/tmp", "")
 728
 729		env := env.NewFromMap(map[string]string{})
 730		resolver := NewShellVariableResolver(env)
 731		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 732		require.NoError(t, err)
 733
 734		require.Equal(t, cfg.Providers.Len(), 1)
 735		customProvider, exists := cfg.Providers.Get("custom-anthropic")
 736		require.True(t, exists)
 737		require.Equal(t, "custom-anthropic", customProvider.ID)
 738		require.Equal(t, "test-key", customProvider.APIKey)
 739		require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
 740		require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
 741	})
 742
 743	t.Run("disabled custom provider is removed", func(t *testing.T) {
 744		cfg := &Config{
 745			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 746				"custom": {
 747					APIKey:  "test-key",
 748					BaseURL: "https://api.custom.com/v1",
 749					Type:    catwalk.TypeOpenAI,
 750					Disable: true,
 751					Models: []catwalk.Model{{
 752						ID: "test-model",
 753					}},
 754				},
 755			}),
 756		}
 757		cfg.setDefaults("/tmp", "")
 758
 759		env := env.NewFromMap(map[string]string{})
 760		resolver := NewShellVariableResolver(env)
 761		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
 762		require.NoError(t, err)
 763
 764		require.Equal(t, cfg.Providers.Len(), 0)
 765		_, exists := cfg.Providers.Get("custom")
 766		require.False(t, exists)
 767	})
 768}
 769
 770func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
 771	t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
 772		knownProviders := []catwalk.Provider{
 773			{
 774				ID:          catwalk.InferenceProviderVertexAI,
 775				APIKey:      "",
 776				APIEndpoint: "",
 777				Models: []catwalk.Model{{
 778					ID: "gemini-pro",
 779				}},
 780			},
 781		}
 782
 783		cfg := &Config{
 784			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 785				"vertexai": {
 786					BaseURL: "custom-url",
 787				},
 788			}),
 789		}
 790		cfg.setDefaults("/tmp", "")
 791
 792		env := env.NewFromMap(map[string]string{
 793			"GOOGLE_GENAI_USE_VERTEXAI": "false",
 794		})
 795		resolver := NewShellVariableResolver(env)
 796		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 797		require.NoError(t, err)
 798
 799		require.Equal(t, cfg.Providers.Len(), 0)
 800		_, exists := cfg.Providers.Get("vertexai")
 801		require.False(t, exists)
 802	})
 803
 804	t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
 805		knownProviders := []catwalk.Provider{
 806			{
 807				ID:          catwalk.InferenceProviderBedrock,
 808				APIKey:      "",
 809				APIEndpoint: "",
 810				Models: []catwalk.Model{{
 811					ID: "anthropic.claude-sonnet-4-20250514-v1:0",
 812				}},
 813			},
 814		}
 815
 816		cfg := &Config{
 817			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 818				"bedrock": {
 819					BaseURL: "custom-url",
 820				},
 821			}),
 822		}
 823		cfg.setDefaults("/tmp", "")
 824
 825		env := env.NewFromMap(map[string]string{})
 826		resolver := NewShellVariableResolver(env)
 827		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 828		require.NoError(t, err)
 829
 830		require.Equal(t, cfg.Providers.Len(), 0)
 831		_, exists := cfg.Providers.Get("bedrock")
 832		require.False(t, exists)
 833	})
 834
 835	t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
 836		knownProviders := []catwalk.Provider{
 837			{
 838				ID:          "openai",
 839				APIKey:      "$MISSING_API_KEY",
 840				APIEndpoint: "https://api.openai.com/v1",
 841				Models: []catwalk.Model{{
 842					ID: "test-model",
 843				}},
 844			},
 845		}
 846
 847		cfg := &Config{
 848			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 849				"openai": {
 850					BaseURL: "custom-url",
 851				},
 852			}),
 853		}
 854		cfg.setDefaults("/tmp", "")
 855
 856		env := env.NewFromMap(map[string]string{})
 857		resolver := NewShellVariableResolver(env)
 858		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 859		require.NoError(t, err)
 860
 861		require.Equal(t, cfg.Providers.Len(), 0)
 862		_, exists := cfg.Providers.Get("openai")
 863		require.False(t, exists)
 864	})
 865
 866	t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
 867		knownProviders := []catwalk.Provider{
 868			{
 869				ID:          "openai",
 870				APIKey:      "$OPENAI_API_KEY",
 871				APIEndpoint: "$MISSING_ENDPOINT",
 872				Models: []catwalk.Model{{
 873					ID: "test-model",
 874				}},
 875			},
 876		}
 877
 878		cfg := &Config{
 879			Providers: csync.NewMapFrom(map[string]ProviderConfig{
 880				"openai": {
 881					APIKey: "test-key",
 882				},
 883			}),
 884		}
 885		cfg.setDefaults("/tmp", "")
 886
 887		env := env.NewFromMap(map[string]string{
 888			"OPENAI_API_KEY": "test-key",
 889		})
 890		resolver := NewShellVariableResolver(env)
 891		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 892		require.NoError(t, err)
 893
 894		require.Equal(t, cfg.Providers.Len(), 1)
 895		_, exists := cfg.Providers.Get("openai")
 896		require.True(t, exists)
 897	})
 898}
 899
 900func TestConfig_defaultModelSelection(t *testing.T) {
 901	t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
 902		knownProviders := []catwalk.Provider{
 903			{
 904				ID:                  "openai",
 905				APIKey:              "abc",
 906				DefaultLargeModelID: "large-model",
 907				DefaultSmallModelID: "small-model",
 908				Models: []catwalk.Model{
 909					{
 910						ID:               "large-model",
 911						DefaultMaxTokens: 1000,
 912					},
 913					{
 914						ID:               "small-model",
 915						DefaultMaxTokens: 500,
 916					},
 917				},
 918			},
 919		}
 920
 921		cfg := &Config{}
 922		cfg.setDefaults("/tmp", "")
 923		env := env.NewFromMap(map[string]string{})
 924		resolver := NewShellVariableResolver(env)
 925		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 926		require.NoError(t, err)
 927
 928		large, small, err := cfg.defaultModelSelection(knownProviders)
 929		require.NoError(t, err)
 930		require.Equal(t, "large-model", large.Model)
 931		require.Equal(t, "openai", large.Provider)
 932		require.Equal(t, int64(1000), large.MaxTokens)
 933		require.Equal(t, "small-model", small.Model)
 934		require.Equal(t, "openai", small.Provider)
 935		require.Equal(t, int64(500), small.MaxTokens)
 936	})
 937	t.Run("should error if no providers configured", func(t *testing.T) {
 938		knownProviders := []catwalk.Provider{
 939			{
 940				ID:                  "openai",
 941				APIKey:              "$MISSING_KEY",
 942				DefaultLargeModelID: "large-model",
 943				DefaultSmallModelID: "small-model",
 944				Models: []catwalk.Model{
 945					{
 946						ID:               "large-model",
 947						DefaultMaxTokens: 1000,
 948					},
 949					{
 950						ID:               "small-model",
 951						DefaultMaxTokens: 500,
 952					},
 953				},
 954			},
 955		}
 956
 957		cfg := &Config{}
 958		cfg.setDefaults("/tmp", "")
 959		env := env.NewFromMap(map[string]string{})
 960		resolver := NewShellVariableResolver(env)
 961		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 962		require.NoError(t, err)
 963
 964		_, _, err = cfg.defaultModelSelection(knownProviders)
 965		require.Error(t, err)
 966	})
 967	t.Run("should error if model is missing", func(t *testing.T) {
 968		knownProviders := []catwalk.Provider{
 969			{
 970				ID:                  "openai",
 971				APIKey:              "abc",
 972				DefaultLargeModelID: "large-model",
 973				DefaultSmallModelID: "small-model",
 974				Models: []catwalk.Model{
 975					{
 976						ID:               "not-large-model",
 977						DefaultMaxTokens: 1000,
 978					},
 979					{
 980						ID:               "small-model",
 981						DefaultMaxTokens: 500,
 982					},
 983				},
 984			},
 985		}
 986
 987		cfg := &Config{}
 988		cfg.setDefaults("/tmp", "")
 989		env := env.NewFromMap(map[string]string{})
 990		resolver := NewShellVariableResolver(env)
 991		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
 992		require.NoError(t, err)
 993		_, _, err = cfg.defaultModelSelection(knownProviders)
 994		require.Error(t, err)
 995	})
 996
 997	t.Run("should configure the default models with a custom provider", func(t *testing.T) {
 998		knownProviders := []catwalk.Provider{
 999			{
1000				ID:                  "openai",
1001				APIKey:              "$MISSING", // will not be included in the config
1002				DefaultLargeModelID: "large-model",
1003				DefaultSmallModelID: "small-model",
1004				Models: []catwalk.Model{
1005					{
1006						ID:               "not-large-model",
1007						DefaultMaxTokens: 1000,
1008					},
1009					{
1010						ID:               "small-model",
1011						DefaultMaxTokens: 500,
1012					},
1013				},
1014			},
1015		}
1016
1017		cfg := &Config{
1018			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1019				"custom": {
1020					APIKey:  "test-key",
1021					BaseURL: "https://api.custom.com/v1",
1022					Models: []catwalk.Model{
1023						{
1024							ID:               "model",
1025							DefaultMaxTokens: 600,
1026						},
1027					},
1028				},
1029			}),
1030		}
1031		cfg.setDefaults("/tmp", "")
1032		env := env.NewFromMap(map[string]string{})
1033		resolver := NewShellVariableResolver(env)
1034		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1035		require.NoError(t, err)
1036		large, small, err := cfg.defaultModelSelection(knownProviders)
1037		require.NoError(t, err)
1038		require.Equal(t, "model", large.Model)
1039		require.Equal(t, "custom", large.Provider)
1040		require.Equal(t, int64(600), large.MaxTokens)
1041		require.Equal(t, "model", small.Model)
1042		require.Equal(t, "custom", small.Provider)
1043		require.Equal(t, int64(600), small.MaxTokens)
1044	})
1045
1046	t.Run("should fail if no model configured", func(t *testing.T) {
1047		knownProviders := []catwalk.Provider{
1048			{
1049				ID:                  "openai",
1050				APIKey:              "$MISSING", // will not be included in the config
1051				DefaultLargeModelID: "large-model",
1052				DefaultSmallModelID: "small-model",
1053				Models: []catwalk.Model{
1054					{
1055						ID:               "not-large-model",
1056						DefaultMaxTokens: 1000,
1057					},
1058					{
1059						ID:               "small-model",
1060						DefaultMaxTokens: 500,
1061					},
1062				},
1063			},
1064		}
1065
1066		cfg := &Config{
1067			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1068				"custom": {
1069					APIKey:  "test-key",
1070					BaseURL: "https://api.custom.com/v1",
1071					Models:  []catwalk.Model{},
1072				},
1073			}),
1074		}
1075		cfg.setDefaults("/tmp", "")
1076		env := env.NewFromMap(map[string]string{})
1077		resolver := NewShellVariableResolver(env)
1078		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1079		require.NoError(t, err)
1080		_, _, err = cfg.defaultModelSelection(knownProviders)
1081		require.Error(t, err)
1082	})
1083	t.Run("should use the default provider first", func(t *testing.T) {
1084		knownProviders := []catwalk.Provider{
1085			{
1086				ID:                  "openai",
1087				APIKey:              "set",
1088				DefaultLargeModelID: "large-model",
1089				DefaultSmallModelID: "small-model",
1090				Models: []catwalk.Model{
1091					{
1092						ID:               "large-model",
1093						DefaultMaxTokens: 1000,
1094					},
1095					{
1096						ID:               "small-model",
1097						DefaultMaxTokens: 500,
1098					},
1099				},
1100			},
1101		}
1102
1103		cfg := &Config{
1104			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1105				"custom": {
1106					APIKey:  "test-key",
1107					BaseURL: "https://api.custom.com/v1",
1108					Models: []catwalk.Model{
1109						{
1110							ID:               "large-model",
1111							DefaultMaxTokens: 1000,
1112						},
1113					},
1114				},
1115			}),
1116		}
1117		cfg.setDefaults("/tmp", "")
1118		env := env.NewFromMap(map[string]string{})
1119		resolver := NewShellVariableResolver(env)
1120		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1121		require.NoError(t, err)
1122		large, small, err := cfg.defaultModelSelection(knownProviders)
1123		require.NoError(t, err)
1124		require.Equal(t, "large-model", large.Model)
1125		require.Equal(t, "openai", large.Provider)
1126		require.Equal(t, int64(1000), large.MaxTokens)
1127		require.Equal(t, "small-model", small.Model)
1128		require.Equal(t, "openai", small.Provider)
1129		require.Equal(t, int64(500), small.MaxTokens)
1130	})
1131}
1132
1133func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1134	t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1135		knownProviders := []catwalk.Provider{
1136			{
1137				ID:          "openai",
1138				APIKey:      "$OPENAI_API_KEY",
1139				APIEndpoint: "https://api.openai.com/v1",
1140				Models: []catwalk.Model{{
1141					ID: "gpt-4",
1142				}},
1143			},
1144		}
1145
1146		// User references openai but doesn't fully specify it (no base_url, no
1147		// models). This should be rejected because disable_default_providers
1148		// treats all providers as custom.
1149		cfg := &Config{
1150			Options: &Options{
1151				DisableDefaultProviders: true,
1152			},
1153			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1154				"openai": {
1155					APIKey: "$OPENAI_API_KEY",
1156				},
1157			}),
1158		}
1159		cfg.setDefaults("/tmp", "")
1160
1161		env := env.NewFromMap(map[string]string{
1162			"OPENAI_API_KEY": "test-key",
1163		})
1164		resolver := NewShellVariableResolver(env)
1165		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1166		require.ErrorContains(t, err, "no custom providers")
1167
1168		// openai should NOT be present because it lacks base_url and models.
1169		require.Equal(t, 0, cfg.Providers.Len())
1170		_, exists := cfg.Providers.Get("openai")
1171		require.False(t, exists, "openai should not be present without full specification")
1172	})
1173
1174	t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1175		knownProviders := []catwalk.Provider{
1176			{
1177				ID:          "openai",
1178				APIKey:      "$OPENAI_API_KEY",
1179				APIEndpoint: "https://api.openai.com/v1",
1180				Models: []catwalk.Model{{
1181					ID: "gpt-4",
1182				}},
1183			},
1184		}
1185
1186		// User fully specifies their provider.
1187		cfg := &Config{
1188			Options: &Options{
1189				DisableDefaultProviders: true,
1190			},
1191			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1192				"my-llm": {
1193					APIKey:  "$MY_API_KEY",
1194					BaseURL: "https://my-llm.example.com/v1",
1195					Models: []catwalk.Model{{
1196						ID: "my-model",
1197					}},
1198				},
1199			}),
1200		}
1201		cfg.setDefaults("/tmp", "")
1202
1203		env := env.NewFromMap(map[string]string{
1204			"MY_API_KEY":     "test-key",
1205			"OPENAI_API_KEY": "test-key",
1206		})
1207		resolver := NewShellVariableResolver(env)
1208		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1209		require.NoError(t, err)
1210
1211		// Only fully specified provider should be present.
1212		require.Equal(t, 1, cfg.Providers.Len())
1213		provider, exists := cfg.Providers.Get("my-llm")
1214		require.True(t, exists, "my-llm should be present")
1215		require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1216		require.Len(t, provider.Models, 1)
1217
1218		// Default openai should NOT be present.
1219		_, exists = cfg.Providers.Get("openai")
1220		require.False(t, exists, "openai should not be present")
1221	})
1222
1223	t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1224		knownProviders := []catwalk.Provider{
1225			{
1226				ID:          "openai",
1227				APIKey:      "$OPENAI_API_KEY",
1228				APIEndpoint: "https://api.openai.com/v1",
1229				Models: []catwalk.Model{{
1230					ID: "gpt-4",
1231				}},
1232			},
1233			{
1234				ID:          "anthropic",
1235				APIKey:      "$ANTHROPIC_API_KEY",
1236				APIEndpoint: "https://api.anthropic.com/v1",
1237				Models: []catwalk.Model{{
1238					ID: "claude-3",
1239				}},
1240			},
1241		}
1242
1243		// User only configures openai, both API keys are available, but option
1244		// is disabled.
1245		cfg := &Config{
1246			Options: &Options{
1247				DisableDefaultProviders: false,
1248			},
1249			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1250				"openai": {
1251					APIKey: "$OPENAI_API_KEY",
1252				},
1253			}),
1254		}
1255		cfg.setDefaults("/tmp", "")
1256
1257		env := env.NewFromMap(map[string]string{
1258			"OPENAI_API_KEY":    "test-key",
1259			"ANTHROPIC_API_KEY": "test-key",
1260		})
1261		resolver := NewShellVariableResolver(env)
1262		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1263		require.NoError(t, err)
1264
1265		// Both providers should be present.
1266		require.Equal(t, 2, cfg.Providers.Len())
1267		_, exists := cfg.Providers.Get("openai")
1268		require.True(t, exists, "openai should be present")
1269		_, exists = cfg.Providers.Get("anthropic")
1270		require.True(t, exists, "anthropic should be present")
1271	})
1272
1273	t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1274		cfg := &Config{
1275			Options: &Options{
1276				DisableDefaultProviders: true,
1277			},
1278			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1279				"my-llm": {
1280					APIKey:  "test-key",
1281					BaseURL: "https://my-llm.example.com/v1",
1282					Models:  []catwalk.Model{}, // No models.
1283				},
1284			}),
1285		}
1286		cfg.setDefaults("/tmp", "")
1287
1288		env := env.NewFromMap(map[string]string{})
1289		resolver := NewShellVariableResolver(env)
1290		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1291		require.ErrorContains(t, err, "no custom providers")
1292
1293		// Provider should be rejected for missing models.
1294		require.Equal(t, 0, cfg.Providers.Len())
1295	})
1296
1297	t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1298		cfg := &Config{
1299			Options: &Options{
1300				DisableDefaultProviders: true,
1301			},
1302			Providers: csync.NewMapFrom(map[string]ProviderConfig{
1303				"my-llm": {
1304					APIKey: "test-key",
1305					Models: []catwalk.Model{{ID: "model"}},
1306					// No BaseURL.
1307				},
1308			}),
1309		}
1310		cfg.setDefaults("/tmp", "")
1311
1312		env := env.NewFromMap(map[string]string{})
1313		resolver := NewShellVariableResolver(env)
1314		err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1315		require.ErrorContains(t, err, "no custom providers")
1316
1317		// Provider should be rejected for missing base_url.
1318		require.Equal(t, 0, cfg.Providers.Len())
1319	})
1320}
1321
1322func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1323	t.Run("sets option from environment variable", func(t *testing.T) {
1324		t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1325
1326		cfg := &Config{}
1327		cfg.setDefaults("/tmp", "")
1328
1329		require.True(t, cfg.Options.DisableDefaultProviders)
1330	})
1331
1332	t.Run("does not override when env var is not set", func(t *testing.T) {
1333		cfg := &Config{
1334			Options: &Options{
1335				DisableDefaultProviders: true,
1336			},
1337		}
1338		cfg.setDefaults("/tmp", "")
1339
1340		require.True(t, cfg.Options.DisableDefaultProviders)
1341	})
1342}
1343
1344func TestConfig_configureSelectedModels(t *testing.T) {
1345	t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
1346		dir := t.TempDir()
1347		globalPath := filepath.Join(dir, "crush.json")
1348		require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
1349
1350		knownProviders := []catwalk.Provider{
1351			{
1352				ID:                  "openai",
1353				APIKey:              "abc",
1354				DefaultLargeModelID: "large-model",
1355				DefaultSmallModelID: "small-model",
1356				Models: []catwalk.Model{
1357					{ID: "large-model", DefaultMaxTokens: 1000},
1358					{ID: "small-model", DefaultMaxTokens: 500},
1359				},
1360			},
1361		}
1362
1363		cfg := &Config{
1364			Models: map[SelectedModelType]SelectedModel{
1365				SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
1366			},
1367		}
1368		cfg.setDefaults(dir, "")
1369		store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1370		env := env.NewFromMap(map[string]string{})
1371		resolver := NewShellVariableResolver(env)
1372		err := cfg.configureProviders(store, env, resolver, knownProviders)
1373		require.NoError(t, err)
1374
1375		err = configureSelectedModels(store, knownProviders, false)
1376		require.NoError(t, err)
1377
1378		// In-memory falls back to default.
1379		require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1380		require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1381
1382		// Disk remains unchanged in reload mode.
1383		data, readErr := os.ReadFile(globalPath)
1384		require.NoError(t, readErr)
1385		require.Contains(t, string(data), `"provider":"ghost"`)
1386		require.Contains(t, string(data), `"model":"missing"`)
1387	})
1388	t.Run("should override defaults", func(t *testing.T) {
1389		knownProviders := []catwalk.Provider{
1390			{
1391				ID:                  "openai",
1392				APIKey:              "abc",
1393				DefaultLargeModelID: "large-model",
1394				DefaultSmallModelID: "small-model",
1395				Models: []catwalk.Model{
1396					{
1397						ID:               "larger-model",
1398						DefaultMaxTokens: 2000,
1399					},
1400					{
1401						ID:               "large-model",
1402						DefaultMaxTokens: 1000,
1403					},
1404					{
1405						ID:               "small-model",
1406						DefaultMaxTokens: 500,
1407					},
1408				},
1409			},
1410		}
1411
1412		cfg := &Config{
1413			Models: map[SelectedModelType]SelectedModel{
1414				"large": {
1415					Model: "larger-model",
1416				},
1417			},
1418		}
1419		cfg.setDefaults("/tmp", "")
1420		env := env.NewFromMap(map[string]string{})
1421		resolver := NewShellVariableResolver(env)
1422		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1423		require.NoError(t, err)
1424
1425		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1426		require.NoError(t, err)
1427		large := cfg.Models[SelectedModelTypeLarge]
1428		small := cfg.Models[SelectedModelTypeSmall]
1429		require.Equal(t, "larger-model", large.Model)
1430		require.Equal(t, "openai", large.Provider)
1431		require.Equal(t, int64(2000), large.MaxTokens)
1432		require.Equal(t, "small-model", small.Model)
1433		require.Equal(t, "openai", small.Provider)
1434		require.Equal(t, int64(500), small.MaxTokens)
1435	})
1436	t.Run("should be possible to use multiple providers", func(t *testing.T) {
1437		knownProviders := []catwalk.Provider{
1438			{
1439				ID:                  "openai",
1440				APIKey:              "abc",
1441				DefaultLargeModelID: "large-model",
1442				DefaultSmallModelID: "small-model",
1443				Models: []catwalk.Model{
1444					{
1445						ID:               "large-model",
1446						DefaultMaxTokens: 1000,
1447					},
1448					{
1449						ID:               "small-model",
1450						DefaultMaxTokens: 500,
1451					},
1452				},
1453			},
1454			{
1455				ID:                  "anthropic",
1456				APIKey:              "abc",
1457				DefaultLargeModelID: "a-large-model",
1458				DefaultSmallModelID: "a-small-model",
1459				Models: []catwalk.Model{
1460					{
1461						ID:               "a-large-model",
1462						DefaultMaxTokens: 1000,
1463					},
1464					{
1465						ID:               "a-small-model",
1466						DefaultMaxTokens: 200,
1467					},
1468				},
1469			},
1470		}
1471
1472		cfg := &Config{
1473			Models: map[SelectedModelType]SelectedModel{
1474				"small": {
1475					Model:     "a-small-model",
1476					Provider:  "anthropic",
1477					MaxTokens: 300,
1478				},
1479			},
1480		}
1481		cfg.setDefaults("/tmp", "")
1482		env := env.NewFromMap(map[string]string{})
1483		resolver := NewShellVariableResolver(env)
1484		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1485		require.NoError(t, err)
1486
1487		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1488		require.NoError(t, err)
1489		large := cfg.Models[SelectedModelTypeLarge]
1490		small := cfg.Models[SelectedModelTypeSmall]
1491		require.Equal(t, "large-model", large.Model)
1492		require.Equal(t, "openai", large.Provider)
1493		require.Equal(t, int64(1000), large.MaxTokens)
1494		require.Equal(t, "a-small-model", small.Model)
1495		require.Equal(t, "anthropic", small.Provider)
1496		require.Equal(t, int64(300), small.MaxTokens)
1497	})
1498
1499	t.Run("should override the max tokens only", func(t *testing.T) {
1500		knownProviders := []catwalk.Provider{
1501			{
1502				ID:                  "openai",
1503				APIKey:              "abc",
1504				DefaultLargeModelID: "large-model",
1505				DefaultSmallModelID: "small-model",
1506				Models: []catwalk.Model{
1507					{
1508						ID:               "large-model",
1509						DefaultMaxTokens: 1000,
1510					},
1511					{
1512						ID:               "small-model",
1513						DefaultMaxTokens: 500,
1514					},
1515				},
1516			},
1517		}
1518
1519		cfg := &Config{
1520			Models: map[SelectedModelType]SelectedModel{
1521				"large": {
1522					MaxTokens: 100,
1523				},
1524			},
1525		}
1526		cfg.setDefaults("/tmp", "")
1527		env := env.NewFromMap(map[string]string{})
1528		resolver := NewShellVariableResolver(env)
1529		err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1530		require.NoError(t, err)
1531
1532		err = configureSelectedModels(testStore(cfg), knownProviders, true)
1533		require.NoError(t, err)
1534		large := cfg.Models[SelectedModelTypeLarge]
1535		require.Equal(t, "large-model", large.Model)
1536		require.Equal(t, "openai", large.Provider)
1537		require.Equal(t, int64(100), large.MaxTokens)
1538	})
1539}
1540
1541func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
1542	// Test that HYPER_API_KEY environment variable works without config
1543	knownProviders := []catwalk.Provider{
1544		{
1545			ID:                  "hyper",
1546			APIKey:              "", // No API key in provider definition
1547			DefaultLargeModelID: "large-model",
1548			DefaultSmallModelID: "small-model",
1549			Models: []catwalk.Model{
1550				{
1551					ID:               "large-model",
1552					DefaultMaxTokens: 1000,
1553				},
1554				{
1555					ID:               "small-model",
1556					DefaultMaxTokens: 500,
1557				},
1558			},
1559		},
1560	}
1561
1562	cfg := &Config{}
1563	cfg.setDefaults("/tmp", "")
1564	env := env.NewFromMap(map[string]string{
1565		"HYPER_API_KEY": "env-api-key",
1566	})
1567	resolver := NewShellVariableResolver(env)
1568	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1569	require.NoError(t, err)
1570	require.Equal(t, 1, cfg.Providers.Len())
1571
1572	// Verify Hyper provider is configured with the env var API key
1573	pc, ok := cfg.Providers.Get("hyper")
1574	require.True(t, ok, "Hyper provider should be configured")
1575	require.Equal(t, "env-api-key", pc.APIKey)
1576	require.Equal(t, "env-api-key", pc.APIKeyTemplate)
1577}
1578
1579func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
1580	// Test that config API key takes precedence when HYPER_API_KEY is also set
1581	knownProviders := []catwalk.Provider{
1582		{
1583			ID:                  "hyper",
1584			APIKey:              "provider-api-key",
1585			DefaultLargeModelID: "large-model",
1586			DefaultSmallModelID: "small-model",
1587			Models: []catwalk.Model{
1588				{
1589					ID:               "large-model",
1590					DefaultMaxTokens: 1000,
1591				},
1592				{
1593					ID:               "small-model",
1594					DefaultMaxTokens: 500,
1595				},
1596			},
1597		},
1598	}
1599
1600	// User has Hyper configured with an API key
1601	cfg := &Config{
1602		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1603			"hyper": {
1604				APIKey: "config-api-key",
1605			},
1606		}),
1607	}
1608	cfg.setDefaults("/tmp", "")
1609
1610	// But they also have HYPER_API_KEY set - env var should take precedence
1611	env := env.NewFromMap(map[string]string{
1612		"HYPER_API_KEY": "env-api-key",
1613	})
1614	resolver := NewShellVariableResolver(env)
1615	err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1616	require.NoError(t, err)
1617	require.Equal(t, 1, cfg.Providers.Len())
1618
1619	// Verify env var takes precedence (as per requirements)
1620	pc, ok := cfg.Providers.Get("hyper")
1621	require.True(t, ok, "Hyper provider should be configured")
1622	require.Equal(t, "env-api-key", pc.APIKey)
1623}
1624
1625// TestConfig_configureProviders_ProviderHeaderResolveError pins
1626// Phase 2 design decision #14: a failing $(cmd) in a provider header
1627// must fail the provider load with a clear message that names the
1628// offending header. The Phase 1 log-and-continue divergence at
1629// load.go:225 is gone; provider headers now share the MCP error
1630// contract.
1631func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1632	knownProviders := []catwalk.Provider{
1633		{
1634			ID:          "openai",
1635			APIKey:      "$OPENAI_API_KEY",
1636			APIEndpoint: "https://api.openai.com/v1",
1637			Models:      []catwalk.Model{{ID: "test-model"}},
1638		},
1639	}
1640
1641	cfg := &Config{
1642		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1643			"openai": {
1644				ExtraHeaders: map[string]string{
1645					// Failing $(...) — inner command exits 1. Must
1646					// propagate as an error, not a silent truncation.
1647					"X-Broken": "$(false)",
1648				},
1649			},
1650		}),
1651	}
1652	cfg.setDefaults("/tmp", "")
1653
1654	testEnv := env.NewFromMap(map[string]string{
1655		"OPENAI_API_KEY": "test-key",
1656		"PATH":           os.Getenv("PATH"),
1657	})
1658	resolver := NewShellVariableResolver(testEnv)
1659
1660	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1661	require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
1662	require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1663}
1664
1665// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads pins
1666// Phase 2 design decisions #11 and #18 from the provider angle: a
1667// Catwalk-style default header like
1668// "OpenAI-Organization": "$OPENAI_ORG_ID" must load cleanly under
1669// lenient nounset (unset → "" → header dropped), not fail the load
1670// and not leave the literal template on the wire.
1671func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1672	knownProviders := []catwalk.Provider{
1673		{
1674			ID:          "openai",
1675			APIKey:      "$OPENAI_API_KEY",
1676			APIEndpoint: "https://api.openai.com/v1",
1677			Models:      []catwalk.Model{{ID: "test-model"}},
1678			DefaultHeaders: map[string]string{
1679				"OpenAI-Organization": "$OPENAI_ORG_ID",
1680			},
1681		},
1682	}
1683
1684	cfg := &Config{}
1685	cfg.setDefaults("/tmp", "")
1686
1687	testEnv := env.NewFromMap(map[string]string{
1688		"OPENAI_API_KEY": "test-key",
1689		"PATH":           os.Getenv("PATH"),
1690	})
1691	resolver := NewShellVariableResolver(testEnv)
1692
1693	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1694	require.NoError(t, err, "optional env-gated header must not fail the load")
1695
1696	pc, ok := cfg.Providers.Get("openai")
1697	require.True(t, ok, "openai provider must still be configured")
1698	_, present := pc.ExtraHeaders["OpenAI-Organization"]
1699	require.False(t, present, "header whose value resolves to empty must be absent")
1700}
1701
1702// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
1703// decision #18 for the literal case: a user-authored
1704// "X-Custom": "" in extra_headers is absent from the resolved map.
1705// Applies to both known- and custom-provider paths; this test
1706// exercises the custom-provider loop.
1707func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
1708	cfg := &Config{
1709		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1710			"my-llm": {
1711				APIKey:  "test-key",
1712				BaseURL: "https://my-llm.example.com/v1",
1713				Type:    catwalk.TypeOpenAI,
1714				Models:  []catwalk.Model{{ID: "m"}},
1715				ExtraHeaders: map[string]string{
1716					"X-Custom": "",
1717					"X-Kept":   "present",
1718				},
1719			},
1720		}),
1721	}
1722	cfg.setDefaults("/tmp", "")
1723
1724	testEnv := env.NewFromMap(map[string]string{
1725		"PATH": os.Getenv("PATH"),
1726	})
1727	resolver := NewShellVariableResolver(testEnv)
1728
1729	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
1730	require.NoError(t, err)
1731
1732	pc, ok := cfg.Providers.Get("my-llm")
1733	require.True(t, ok)
1734	_, present := pc.ExtraHeaders["X-Custom"]
1735	require.False(t, present, "literal empty-string header must be dropped")
1736	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1737}
1738
1739// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
1740// decision #18 for the non-failing empty case: $(echo) exits 0 with
1741// empty output, resolves cleanly to "", and must be dropped the same
1742// way an unset bare $VAR is. Exercises the known-provider loop.
1743func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1744	knownProviders := []catwalk.Provider{
1745		{
1746			ID:          "openai",
1747			APIKey:      "$OPENAI_API_KEY",
1748			APIEndpoint: "https://api.openai.com/v1",
1749			Models:      []catwalk.Model{{ID: "test-model"}},
1750			DefaultHeaders: map[string]string{
1751				"X-Empty": "$(echo)",
1752				"X-Kept":  "present",
1753			},
1754		},
1755	}
1756
1757	cfg := &Config{}
1758	cfg.setDefaults("/tmp", "")
1759
1760	testEnv := env.NewFromMap(map[string]string{
1761		"OPENAI_API_KEY": "test-key",
1762		"PATH":           os.Getenv("PATH"),
1763	})
1764	resolver := NewShellVariableResolver(testEnv)
1765
1766	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1767	require.NoError(t, err)
1768
1769	pc, ok := cfg.Providers.Get("openai")
1770	require.True(t, ok)
1771	_, present := pc.ExtraHeaders["X-Empty"]
1772	require.False(t, present, "$(echo) → empty → header must be dropped")
1773	require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1774}
1775
1776// TestConfig_configureProviders_UnsetAPIKeySkipsProvider pins Phase 2
1777// Step 12 / design decision #15: under the lenient-nounset shell
1778// resolver, $UNSET_API_KEY expands to ("", nil) rather than ("", err),
1779// and the existing `v == "" || err != nil` skip path at load.go:331
1780// still drops the provider. The slog.Warn line is emitted on the same
1781// path but is not asserted here — internal/config/load_test.go's
1782// TestMain replaces the default slog handler with an io.Discard
1783// writer, so capturing that log line would require mid-test handler
1784// swapping and a sync.Mutex dance that adds more flake surface than
1785// signal. The observable outcome (provider absent from the map) is
1786// what downstream code — model picker, agent wiring — actually reads,
1787// so that's what we pin.
1788func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
1789	knownProviders := []catwalk.Provider{
1790		{
1791			ID:          "openai",
1792			APIKey:      "$SOMETHING_UNSET",
1793			APIEndpoint: "https://api.openai.com/v1",
1794			Models:      []catwalk.Model{{ID: "test-model"}},
1795		},
1796	}
1797
1798	// Existing user config for this known provider so the load.go:332
1799	// `if configExists` branch fires and actually calls Providers.Del.
1800	// Without it the provider was never in the map to begin with and
1801	// the test would pass trivially.
1802	cfg := &Config{
1803		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1804			"openai": {BaseURL: "custom-url"},
1805		}),
1806	}
1807	cfg.setDefaults("/tmp", "")
1808
1809	testEnv := env.NewFromMap(map[string]string{
1810		"PATH": os.Getenv("PATH"),
1811	})
1812	resolver := NewShellVariableResolver(testEnv)
1813
1814	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1815	require.NoError(t, err, "skip path must not surface as a load error")
1816
1817	require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
1818	_, exists := cfg.Providers.Get("openai")
1819	require.False(t, exists)
1820}
1821
1822// TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
1823// that the two failure modes for APIKey — ("", nil) from an unset var
1824// under lenient nounset and ("", err) from a failing $(cmd) — are
1825// equivalent for the skip outcome at load.go:331. The `v == "" ||
1826// err != nil` check fires on either branch; this test locks in that
1827// equivalence so a future refactor that splits the check into two
1828// paths doesn't accidentally start propagating $(false) as a load
1829// error while keeping unset-var as a silent skip (or vice versa).
1830func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
1831	knownProviders := []catwalk.Provider{
1832		{
1833			ID:          "openai",
1834			APIKey:      "$(false)",
1835			APIEndpoint: "https://api.openai.com/v1",
1836			Models:      []catwalk.Model{{ID: "test-model"}},
1837		},
1838	}
1839
1840	cfg := &Config{
1841		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1842			"openai": {BaseURL: "custom-url"},
1843		}),
1844	}
1845	cfg.setDefaults("/tmp", "")
1846
1847	testEnv := env.NewFromMap(map[string]string{
1848		"PATH": os.Getenv("PATH"),
1849	})
1850	resolver := NewShellVariableResolver(testEnv)
1851
1852	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1853	require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
1854
1855	require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
1856	_, exists := cfg.Providers.Get("openai")
1857	require.False(t, exists)
1858}
1859
1860// TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
1861// the same contract on the Azure path at load.go:287 — APIEndpoint is
1862// the field that gates Azure and goes through the same
1863// `v == "" || err != nil` skip check. Covered here so both branches
1864// of the shared skip pattern (APIKey default path and APIEndpoint
1865// Azure path) are tested; a future refactor that unifies them can
1866// rely on these two tests to catch drift.
1867func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
1868	knownProviders := []catwalk.Provider{
1869		{
1870			ID:          catwalk.InferenceProviderAzure,
1871			APIKey:      "test-key",
1872			APIEndpoint: "$UNSET_AZURE_ENDPOINT",
1873			Models:      []catwalk.Model{{ID: "test-model"}},
1874		},
1875	}
1876
1877	cfg := &Config{
1878		Providers: csync.NewMapFrom(map[string]ProviderConfig{
1879			"azure": {BaseURL: ""},
1880		}),
1881	}
1882	cfg.setDefaults("/tmp", "")
1883
1884	testEnv := env.NewFromMap(map[string]string{
1885		"PATH": os.Getenv("PATH"),
1886	})
1887	resolver := NewShellVariableResolver(testEnv)
1888
1889	err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1890	require.NoError(t, err)
1891
1892	require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
1893	_, exists := cfg.Providers.Get("azure")
1894	require.False(t, exists)
1895}