load_test.go

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