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