on main help sign in
feat: auto-discover models from openai-compat providers

Add model discovery via /v1/models endpoint for custom providers, with concurrent fetching, shared HTTP helper, enricher registry, and IsKnownCustomProvider to decouple coordinator from enrichers. ๐Ÿ’˜ Generated with Crush Assisted-by: Crush:qwen3.7-max

commit7303158426f938ac1648e63fdaba900658ff752b authorKieran Klukas <kieran@dunkirk.sh> dateWed 27 May 2026, 12:23 (5 months ago) parentd3af321
8 files changed+661โˆ’56
internal/agent/coordinator.go+15 internal/config/config.go+7 internal/config/load.go+77โˆ’6 internal/config/load_test.go+180โˆ’49 internal/config/store.go+1โˆ’1 internal/discover/discover.go+135 internal/discover/discover_test.go+202 internal/discover/enricher.go+44
internal/agent/coordinator.go+15
@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/agent/prompt"
2424 "github.com/charmbracelet/crush/internal/agent/tools"
2525 "github.com/charmbracelet/crush/internal/config"
26+ "github.com/charmbracelet/crush/internal/discover"
2627 "github.com/charmbracelet/crush/internal/event"
2728 "github.com/charmbracelet/crush/internal/filetracker"
2829 "github.com/charmbracelet/crush/internal/history"
@@ -511,6 +512,15 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
511512 if err == nil {
512513 options[openaicompat.Name] = parsed
513514 }
515+ default:
516+ // Known custom providers (litellm, ollama, omlx) are
517+ // openai-compat under the hood.
518+ if discover.IsKnownCustomProvider(string(providerCfg.Type)) {
519+ parsed, err := openaicompat.ParseOptions(mergedOptions)
520+ if err == nil {
521+ options[openaicompat.Name] = parsed
522+ }
523+ }
514524 }
515525
516526 return options
@@ -1030,6 +1040,11 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
10301040 }
10311041 return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
10321042 default:
1043+ // Known custom providers (litellm, ollama, omlx) are
1044+ // openai-compat under the hood.
1045+ if discover.IsKnownCustomProvider(string(providerCfg.Type)) {
1046+ return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
1047+ }
10331048 return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
10341049 }
10351050}
internal/config/config.go+7
@@ -133,6 +133,13 @@ type ProviderConfig struct {
133133 // Skip cost accumulation for this provider when using subscription or flat rate billing.
134134 FlatRate bool `json:"flat_rate,omitempty" jsonschema:"description=Flat-rate mode for this provider"`
135135
136+ // AutoDiscoverModels controls model discovery via /v1/models endpoint.
137+ // When Models is empty and this is nil or true, Crush auto-discovers
138+ // models. When true and Models is non-empty, discovered models are
139+ // merged in (user-specified models take precedence). When false,
140+ // only explicitly listed models are used.
141+ AutoDiscoverModels *bool `json:"discover_models,omitempty" jsonschema:"description=Auto-discover models from /v1/models endpoint. When true with existing models they are merged (yours win),default=true"`
142+
136143 // The provider models
137144 Models []catwalk.Model `json:"models,omitempty" jsonschema:"description=List of models available from this provider"`
138145}
internal/config/load.go+77โˆ’6
@@ -15,11 +15,14 @@ import (
1515 "slices"
1616 "strconv"
1717 "strings"
18+ "sync"
1819 "testing"
20+ "time"
1921
2022 "charm.land/catwalk/pkg/catwalk"
2123 "github.com/charmbracelet/crush/internal/agent/hyper"
2224 "github.com/charmbracelet/crush/internal/csync"
25+ "github.com/charmbracelet/crush/internal/discover"
2326 "github.com/charmbracelet/crush/internal/env"
2427 "github.com/charmbracelet/crush/internal/filepathext"
2528 "github.com/charmbracelet/crush/internal/fsext"
@@ -113,7 +116,7 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
113116 store.reloadMu.Lock()
114117 defer store.reloadMu.Unlock()
115118
116โˆ’ if err := cfg.configureProviders(store, env, valueResolver, store.knownProviders); err != nil {
119+ if err := cfg.configureProviders(context.Background(), store, env, valueResolver, store.knownProviders); err != nil {
117120 return nil, fmt.Errorf("failed to configure providers: %w", err)
118121 }
119122
@@ -171,7 +174,7 @@ func PushPopCrushEnv() func() {
171174 return restore
172175}
173176
174โˆ’func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
177+func (c *Config) configureProviders(ctx context.Context, store *ConfigStore, env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
175178 knownProviderNames := make(map[string]bool)
176179 restore := PushPopCrushEnv()
177180 defer restore()
@@ -340,18 +343,69 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
340343 c.Providers.Set(string(p.ID), prepared)
341344 }
342345
343โˆ’ // validate the custom providers
346+ // Discover models concurrently for custom providers that need it.
347+ // A provider needs discovery when discover_models is explicitly true,
348+ // or when the models list is empty (auto-trigger, unless opted out).
349+ type discoveryResult struct {
350+ models []catwalk.Model
351+ err error
352+ }
353+
354+ discoveryResults := make(map[string]discoveryResult)
355+ var mu sync.Mutex
356+ var wg sync.WaitGroup
357+
358+ discoverCtx, discoverCancel := context.WithTimeout(ctx, 3*time.Second)
359+ for id, pc := range c.Providers.Seq2() {
360+ if knownProviderNames[id] {
361+ continue
362+ }
363+ if pc.Disable || pc.BaseURL == "" {
364+ continue
365+ }
366+ wantsDiscovery := pc.AutoDiscoverModels != nil && *pc.AutoDiscoverModels
367+ autoTrigger := len(pc.Models) == 0 && (pc.AutoDiscoverModels == nil || *pc.AutoDiscoverModels)
368+ if !wantsDiscovery && !autoTrigger {
369+ continue
370+ }
371+ providerID := cmp.Or(pc.ID, id)
372+ cfg := discover.Config{
373+ ID: providerID,
374+ BaseURL: pc.BaseURL,
375+ APIKey: pc.APIKey,
376+ ExtraHeaders: pc.ExtraHeaders,
377+ ExistingModels: pc.Models,
378+ }
379+ providerType := cmp.Or(pc.Type, catwalk.TypeOpenAICompat)
380+ wg.Go(func() {
381+ models, err := discover.DiscoverModels(discoverCtx, cfg, resolver)
382+ if err == nil && len(models) > 0 {
383+ if enricher := discover.GetEnricher(string(providerType)); enricher != nil {
384+ models, _ = enricher.EnrichModels(discoverCtx, cfg, resolver, models)
385+ }
386+ }
387+ mu.Lock()
388+ discoveryResults[id] = discoveryResult{models: models, err: err}
389+ mu.Unlock()
390+ })
391+ }
392+ wg.Wait()
393+ discoverCancel()
394+
395+ // Validate the custom providers.
344396 for id, providerConfig := range c.Providers.Seq2() {
345397 if knownProviderNames[id] {
346398 continue
347399 }
348400
349โˆ’ // Make sure the provider ID is set
401+ // Make sure the provider ID is set.
350402 providerConfig.ID = id
351403 providerConfig.Name = cmp.Or(providerConfig.Name, id) // Use ID as name if not set
352โˆ’ // default to OpenAI if not set
404+ // Default to OpenAI if not set.
353405 providerConfig.Type = cmp.Or(providerConfig.Type, catwalk.TypeOpenAICompat)
354โˆ’ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) && providerConfig.Type != hyper.Name {
406+ if !slices.Contains(catwalk.KnownProviderTypes(), providerConfig.Type) &&
407+ providerConfig.Type != hyper.Name &&
408+ !discover.IsKnownCustomProvider(string(providerConfig.Type)) {
355409 slog.Warn("Skipping custom provider due to unsupported provider type", "provider", id)
356410 c.Providers.Del(id)
357411 continue
@@ -370,11 +424,28 @@ func (c *Config) configureProviders(store *ConfigStore, env env.Env, resolver Va
370424 c.Providers.Del(id)
371425 continue
372426 }
427+
428+ // Apply discovery results if available.
429+ if result, ok := discoveryResults[id]; ok {
430+ if result.err != nil {
431+ slog.Warn("Model discovery failed", "provider", id, "error", result.err)
432+ if len(providerConfig.Models) == 0 {
433+ slog.Warn("Skipping provider with no models after failed discovery", "provider", id)
434+ c.Providers.Del(id)
435+ continue
436+ }
437+ } else if len(result.models) > 0 {
438+ providerConfig.Models = result.models
439+ slog.Info("Discovered models for provider", "provider", id, "count", len(result.models))
440+ }
441+ }
442+
373443 if len(providerConfig.Models) == 0 {
374444 slog.Warn("Skipping custom provider because the provider has no models", "provider", id)
375445 c.Providers.Del(id)
376446 continue
377447 }
448+
378449 apiKey, err := resolver.ResolveValue(providerConfig.APIKey)
379450 if apiKey == "" || err != nil {
380451 slog.Warn("Provider is missing API key, this might be OK for local providers", "provider", id)
internal/config/load_test.go+180โˆ’49
@@ -1,8 +1,11 @@
11package config
22
33import (
4+ "context"
45 "io"
56 "log/slog"
7+ "net/http"
8+ "net/http/httptest"
69 "os"
710 "os/exec"
811 "path/filepath"
@@ -317,7 +320,7 @@ func TestConfig_configureProviders(t *testing.T) {
317320 "OPENAI_API_KEY": "test-key",
318321 })
319322 resolver := NewShellVariableResolver(env)
320โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
323+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
321324 require.NoError(t, err)
322325 require.Equal(t, 1, cfg.Providers.Len())
323326
@@ -360,7 +363,7 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
360363 "OPENAI_API_KEY": "test-key",
361364 })
362365 resolver := NewShellVariableResolver(env)
363โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
366+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
364367 require.NoError(t, err)
365368 require.Equal(t, 1, cfg.Providers.Len())
366369
@@ -402,7 +405,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
402405 "OPENAI_API_KEY": "test-key",
403406 })
404407 resolver := NewShellVariableResolver(env)
405โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
408+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
406409 require.NoError(t, err)
407410 // Should be to because of the env variable
408411 require.Equal(t, cfg.Providers.Len(), 2)
@@ -438,7 +441,7 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
438441 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
439442 })
440443 resolver := NewShellVariableResolver(env)
441โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
444+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
442445 require.NoError(t, err)
443446 require.Equal(t, cfg.Providers.Len(), 1)
444447
@@ -464,7 +467,7 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
464467 cfg.setDefaults("/tmp", "")
465468 env := env.NewFromMap(map[string]string{})
466469 resolver := NewShellVariableResolver(env)
467โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
470+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
468471 require.NoError(t, err)
469472 // Provider should not be configured without credentials
470473 require.Equal(t, cfg.Providers.Len(), 0)
@@ -489,7 +492,7 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
489492 "VERTEXAI_LOCATION": "us-central1",
490493 })
491494 resolver := NewShellVariableResolver(env)
492โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
495+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
493496 require.NoError(t, err)
494497 require.Equal(t, cfg.Providers.Len(), 1)
495498
@@ -521,7 +524,7 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
521524 "GOOGLE_CLOUD_LOCATION": "us-central1",
522525 })
523526 resolver := NewShellVariableResolver(env)
524โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
527+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
525528 require.NoError(t, err)
526529 // Provider should not be configured without proper credentials
527530 require.Equal(t, cfg.Providers.Len(), 0)
@@ -546,7 +549,7 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
546549 "GOOGLE_CLOUD_LOCATION": "us-central1",
547550 })
548551 resolver := NewShellVariableResolver(env)
549โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
552+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
550553 require.NoError(t, err)
551554 // Provider should not be configured without project
552555 require.Equal(t, cfg.Providers.Len(), 0)
@@ -570,7 +573,7 @@ func TestConfig_configureProvidersSetProviderID(t *testing.T) {
570573 "OPENAI_API_KEY": "test-key",
571574 })
572575 resolver := NewShellVariableResolver(env)
573โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
576+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
574577 require.NoError(t, err)
575578 require.Equal(t, cfg.Providers.Len(), 1)
576579
@@ -761,7 +764,7 @@ func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
761764 "OPENAI_API_KEY": "test-key",
762765 })
763766 resolver := NewShellVariableResolver(env)
764โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
767+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
765768 require.NoError(t, err)
766769
767770 require.Equal(t, cfg.Providers.Len(), 1)
@@ -789,7 +792,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
789792
790793 env := env.NewFromMap(map[string]string{})
791794 resolver := NewShellVariableResolver(env)
792โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
795+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
793796 require.NoError(t, err)
794797
795798 require.Equal(t, cfg.Providers.Len(), 1)
@@ -812,7 +815,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
812815
813816 env := env.NewFromMap(map[string]string{})
814817 resolver := NewShellVariableResolver(env)
815โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
818+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
816819 require.NoError(t, err)
817820
818821 require.Equal(t, cfg.Providers.Len(), 0)
@@ -820,7 +823,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
820823 require.False(t, exists)
821824 })
822825
823โˆ’ t.Run("custom provider with no models is removed", func(t *testing.T) {
826+ t.Run("custom provider with no models attempts discovery and is removed on failure", func(t *testing.T) {
824827 cfg := &Config{
825828 Providers: csync.NewMapFrom(map[string]ProviderConfig{
826829 "custom": {
@@ -834,14 +837,142 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
834837
835838 env := env.NewFromMap(map[string]string{})
836839 resolver := NewShellVariableResolver(env)
837โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
840+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
838841 require.NoError(t, err)
839842
840โˆ’ require.Equal(t, cfg.Providers.Len(), 0)
843+ // Discovery fails (unreachable URL) so provider is removed.
844+ require.Equal(t, 0, cfg.Providers.Len())
845+ _, exists := cfg.Providers.Get("custom")
846+ require.False(t, exists)
847+ })
848+
849+ t.Run("custom provider with no models and discover_models:false is removed", func(t *testing.T) {
850+ discoverFalse := false
851+ cfg := &Config{
852+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
853+ "custom": {
854+ APIKey: "test-key",
855+ BaseURL: "https://api.custom.com/v1",
856+ Models: []catwalk.Model{},
857+ AutoDiscoverModels: &discoverFalse,
858+ },
859+ }),
860+ }
861+ cfg.setDefaults("/tmp", "")
862+
863+ env := env.NewFromMap(map[string]string{})
864+ resolver := NewShellVariableResolver(env)
865+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
866+ require.NoError(t, err)
867+
868+ require.Equal(t, 0, cfg.Providers.Len())
841869 _, exists := cfg.Providers.Get("custom")
842870 require.False(t, exists)
843871 })
844872
873+ t.Run("custom provider with models and discover_models:true merges discovered models", func(t *testing.T) {
874+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
875+ w.Header().Set("Content-Type", "application/json")
876+ _, _ = w.Write([]byte(`{"data": [
877+ {"id": "existing-model", "object": "model"},
878+ {"id": "discovered-model", "object": "model"}
879+ ]}`))
880+ }))
881+ defer server.Close()
882+
883+ discoverTrue := true
884+ cfg := &Config{
885+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
886+ "custom": {
887+ APIKey: "test-key",
888+ BaseURL: server.URL + "/v1",
889+ Models: []catwalk.Model{
890+ {ID: "existing-model", Name: "My Custom Name", ContextWindow: 200000},
891+ },
892+ AutoDiscoverModels: &discoverTrue,
893+ },
894+ }),
895+ }
896+ cfg.setDefaults("/tmp", "")
897+
898+ env := env.NewFromMap(map[string]string{})
899+ resolver := NewShellVariableResolver(env)
900+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
901+ require.NoError(t, err)
902+
903+ require.Equal(t, 1, cfg.Providers.Len())
904+ p, exists := cfg.Providers.Get("custom")
905+ require.True(t, exists)
906+ require.Len(t, p.Models, 2)
907+
908+ // User-specified model keeps its custom fields.
909+ require.Equal(t, "existing-model", p.Models[0].ID)
910+ require.Equal(t, "My Custom Name", p.Models[0].Name)
911+ require.Equal(t, int64(200000), p.Models[0].ContextWindow)
912+
913+ // Discovered model is appended.
914+ require.Equal(t, "discovered-model", p.Models[1].ID)
915+ })
916+
917+ t.Run("custom provider with models and no discover_models uses only listed models", func(t *testing.T) {
918+ cfg := &Config{
919+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
920+ "custom": {
921+ APIKey: "test-key",
922+ BaseURL: "https://api.custom.com/v1",
923+ Models: []catwalk.Model{
924+ {ID: "my-model", Name: "My Model"},
925+ },
926+ },
927+ }),
928+ }
929+ cfg.setDefaults("/tmp", "")
930+
931+ env := env.NewFromMap(map[string]string{})
932+ resolver := NewShellVariableResolver(env)
933+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
934+ require.NoError(t, err)
935+
936+ require.Equal(t, 1, cfg.Providers.Len())
937+ p, exists := cfg.Providers.Get("custom")
938+ require.True(t, exists)
939+ require.Len(t, p.Models, 1)
940+ require.Equal(t, "my-model", p.Models[0].ID)
941+ })
942+
943+ t.Run("custom provider with no models auto-discovers successfully", func(t *testing.T) {
944+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
945+ w.Header().Set("Content-Type", "application/json")
946+ _, _ = w.Write([]byte(`{"data": [
947+ {"id": "auto-model-a", "object": "model"},
948+ {"id": "auto-model-b", "object": "model"}
949+ ]}`))
950+ }))
951+ defer server.Close()
952+
953+ cfg := &Config{
954+ Providers: csync.NewMapFrom(map[string]ProviderConfig{
955+ "custom": {
956+ APIKey: "test-key",
957+ BaseURL: server.URL + "/v1",
958+ },
959+ }),
960+ }
961+ cfg.setDefaults("/tmp", "")
962+
963+ env := env.NewFromMap(map[string]string{})
964+ resolver := NewShellVariableResolver(env)
965+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
966+ require.NoError(t, err)
967+
968+ require.Equal(t, 1, cfg.Providers.Len())
969+ p, exists := cfg.Providers.Get("custom")
970+ require.True(t, exists)
971+ require.Len(t, p.Models, 2)
972+ require.Equal(t, "auto-model-a", p.Models[0].ID)
973+ require.Equal(t, "auto-model-b", p.Models[1].ID)
974+ })
975+
845976 t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
846977 cfg := &Config{
847978 Providers: csync.NewMapFrom(map[string]ProviderConfig{
@@ -859,7 +990,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
859990
860991 env := env.NewFromMap(map[string]string{})
861992 resolver := NewShellVariableResolver(env)
862โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
993+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
863994 require.NoError(t, err)
864995
865996 require.Equal(t, cfg.Providers.Len(), 0)
@@ -884,7 +1015,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
8841015
8851016 env := env.NewFromMap(map[string]string{})
8861017 resolver := NewShellVariableResolver(env)
887โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1018+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
8881019 require.NoError(t, err)
8891020
8901021 require.Equal(t, cfg.Providers.Len(), 1)
@@ -912,7 +1043,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
9121043
9131044 env := env.NewFromMap(map[string]string{})
9141045 resolver := NewShellVariableResolver(env)
915โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1046+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
9161047 require.NoError(t, err)
9171048
9181049 require.Equal(t, cfg.Providers.Len(), 1)
@@ -942,7 +1073,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
9421073
9431074 env := env.NewFromMap(map[string]string{})
9441075 resolver := NewShellVariableResolver(env)
945โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1076+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
9461077 require.NoError(t, err)
9471078
9481079 require.Equal(t, cfg.Providers.Len(), 0)
@@ -977,7 +1108,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
9771108 "GOOGLE_GENAI_USE_VERTEXAI": "false",
9781109 })
9791110 resolver := NewShellVariableResolver(env)
980โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1111+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
9811112 require.NoError(t, err)
9821113
9831114 require.Equal(t, cfg.Providers.Len(), 0)
@@ -1008,7 +1139,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
10081139
10091140 env := env.NewFromMap(map[string]string{})
10101141 resolver := NewShellVariableResolver(env)
1011โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1142+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
10121143 require.NoError(t, err)
10131144
10141145 require.Equal(t, cfg.Providers.Len(), 0)
@@ -1039,7 +1170,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
10391170
10401171 env := env.NewFromMap(map[string]string{})
10411172 resolver := NewShellVariableResolver(env)
1042โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1173+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
10431174 require.NoError(t, err)
10441175
10451176 require.Equal(t, cfg.Providers.Len(), 0)
@@ -1072,7 +1203,7 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
10721203 "OPENAI_API_KEY": "test-key",
10731204 })
10741205 resolver := NewShellVariableResolver(env)
1075โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1206+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
10761207 require.NoError(t, err)
10771208
10781209 require.Equal(t, cfg.Providers.Len(), 1)
@@ -1106,7 +1237,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
11061237 cfg.setDefaults("/tmp", "")
11071238 env := env.NewFromMap(map[string]string{})
11081239 resolver := NewShellVariableResolver(env)
1109โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1240+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
11101241 require.NoError(t, err)
11111242
11121243 large, small, err := cfg.defaultModelSelection(knownProviders)
@@ -1142,7 +1273,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
11421273 cfg.setDefaults("/tmp", "")
11431274 env := env.NewFromMap(map[string]string{})
11441275 resolver := NewShellVariableResolver(env)
1145โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1276+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
11461277 require.NoError(t, err)
11471278
11481279 _, _, err = cfg.defaultModelSelection(knownProviders)
@@ -1172,7 +1303,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
11721303 cfg.setDefaults("/tmp", "")
11731304 env := env.NewFromMap(map[string]string{})
11741305 resolver := NewShellVariableResolver(env)
1175โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1306+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
11761307 require.NoError(t, err)
11771308 _, _, err = cfg.defaultModelSelection(knownProviders)
11781309 require.NoError(t, err)
@@ -1215,7 +1346,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
12151346 cfg.setDefaults("/tmp", "")
12161347 env := env.NewFromMap(map[string]string{})
12171348 resolver := NewShellVariableResolver(env)
1218โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1349+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
12191350 require.NoError(t, err)
12201351 large, small, err := cfg.defaultModelSelection(knownProviders)
12211352 require.NoError(t, err)
@@ -1259,7 +1390,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
12591390 cfg.setDefaults("/tmp", "")
12601391 env := env.NewFromMap(map[string]string{})
12611392 resolver := NewShellVariableResolver(env)
1262โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1393+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
12631394 require.NoError(t, err)
12641395 _, _, err = cfg.defaultModelSelection(knownProviders)
12651396 require.Error(t, err)
@@ -1301,7 +1432,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
13011432 cfg.setDefaults("/tmp", "")
13021433 env := env.NewFromMap(map[string]string{})
13031434 resolver := NewShellVariableResolver(env)
1304โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1435+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
13051436 require.NoError(t, err)
13061437 large, small, err := cfg.defaultModelSelection(knownProviders)
13071438 require.NoError(t, err)
@@ -1346,7 +1477,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
13461477 "OPENAI_API_KEY": "test-key",
13471478 })
13481479 resolver := NewShellVariableResolver(env)
1349โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1480+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
13501481 require.ErrorContains(t, err, "no custom providers")
13511482
13521483 // openai should NOT be present because it lacks base_url and models.
@@ -1389,7 +1520,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
13891520 "OPENAI_API_KEY": "test-key",
13901521 })
13911522 resolver := NewShellVariableResolver(env)
1392โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1523+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
13931524 require.NoError(t, err)
13941525
13951526 // Only fully specified provider should be present.
@@ -1443,7 +1574,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
14431574 "ANTHROPIC_API_KEY": "test-key",
14441575 })
14451576 resolver := NewShellVariableResolver(env)
1446โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1577+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
14471578 require.NoError(t, err)
14481579
14491580 // Both providers should be present.
@@ -1454,7 +1585,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
14541585 require.True(t, exists, "anthropic should be present")
14551586 })
14561587
1457โˆ’ t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1588+ t.Run("when enabled, provider missing models attempts discovery but still triggers no-custom-providers error", func(t *testing.T) {
14581589 cfg := &Config{
14591590 Options: &Options{
14601591 DisableDefaultProviders: true,
@@ -1471,10 +1602,10 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
14711602
14721603 env := env.NewFromMap(map[string]string{})
14731604 resolver := NewShellVariableResolver(env)
1474โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1605+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
14751606 require.ErrorContains(t, err, "no custom providers")
14761607
1477โˆ’ // Provider should be rejected for missing models.
1608+ // Discovery fails (unreachable URL) so provider is removed.
14781609 require.Equal(t, 0, cfg.Providers.Len())
14791610 })
14801611
@@ -1495,7 +1626,7 @@ func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
14951626
14961627 env := env.NewFromMap(map[string]string{})
14971628 resolver := NewShellVariableResolver(env)
1498โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1629+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, []catwalk.Provider{})
14991630 require.ErrorContains(t, err, "no custom providers")
15001631
15011632 // Provider should be rejected for missing base_url.
@@ -1553,7 +1684,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
15531684 store := &ConfigStore{config: cfg, globalDataPath: globalPath}
15541685 env := env.NewFromMap(map[string]string{})
15551686 resolver := NewShellVariableResolver(env)
1556โˆ’ err := cfg.configureProviders(store, env, resolver, knownProviders)
1687+ err := cfg.configureProviders(context.Background(), store, env, resolver, knownProviders)
15571688 require.NoError(t, err)
15581689
15591690 err = configureSelectedModels(store, knownProviders, false)
@@ -1603,7 +1734,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
16031734 cfg.setDefaults("/tmp", "")
16041735 env := env.NewFromMap(map[string]string{})
16051736 resolver := NewShellVariableResolver(env)
1606โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1737+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
16071738 require.NoError(t, err)
16081739
16091740 err = configureSelectedModels(testStore(cfg), knownProviders, true)
@@ -1665,7 +1796,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
16651796 cfg.setDefaults("/tmp", "")
16661797 env := env.NewFromMap(map[string]string{})
16671798 resolver := NewShellVariableResolver(env)
1668โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1799+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
16691800 require.NoError(t, err)
16701801
16711802 err = configureSelectedModels(testStore(cfg), knownProviders, true)
@@ -1710,7 +1841,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
17101841 cfg.setDefaults("/tmp", "")
17111842 env := env.NewFromMap(map[string]string{})
17121843 resolver := NewShellVariableResolver(env)
1713โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1844+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
17141845 require.NoError(t, err)
17151846
17161847 err = configureSelectedModels(testStore(cfg), knownProviders, true)
@@ -1749,7 +1880,7 @@ func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
17491880 "HYPER_API_KEY": "env-api-key",
17501881 })
17511882 resolver := NewShellVariableResolver(env)
1752โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1883+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
17531884 require.NoError(t, err)
17541885 require.Equal(t, 1, cfg.Providers.Len())
17551886
@@ -1796,7 +1927,7 @@ func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T)
17961927 "HYPER_API_KEY": "env-api-key",
17971928 })
17981929 resolver := NewShellVariableResolver(env)
1799โˆ’ err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1930+ err := cfg.configureProviders(context.Background(), testStore(cfg), env, resolver, knownProviders)
18001931 require.NoError(t, err)
18011932 require.Equal(t, 1, cfg.Providers.Len())
18021933
@@ -1839,7 +1970,7 @@ func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
18391970 })
18401971 resolver := NewShellVariableResolver(testEnv)
18411972
1842โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1973+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
18431974 require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
18441975 require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
18451976}
@@ -1871,7 +2002,7 @@ func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T)
18712002 })
18722003 resolver := NewShellVariableResolver(testEnv)
18732004
1874โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2005+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
18752006 require.NoError(t, err, "optional env-gated header must not fail the load")
18762007
18772008 pc, ok := cfg.Providers.Get("openai")
@@ -1907,7 +2038,7 @@ func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
19072038 })
19082039 resolver := NewShellVariableResolver(testEnv)
19092040
1910โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
2041+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, []catwalk.Provider{})
19112042 require.NoError(t, err)
19122043
19132044 pc, ok := cfg.Providers.Get("my-llm")
@@ -1944,7 +2075,7 @@ func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
19442075 })
19452076 resolver := NewShellVariableResolver(testEnv)
19462077
1947โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2078+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
19482079 require.NoError(t, err)
19492080
19502081 pc, ok := cfg.Providers.Get("openai")
@@ -1992,7 +2123,7 @@ func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
19922123 })
19932124 resolver := NewShellVariableResolver(testEnv)
19942125
1995โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2126+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
19962127 require.NoError(t, err, "skip path must not surface as a load error")
19972128
19982129 require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
@@ -2030,7 +2161,7 @@ func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
20302161 })
20312162 resolver := NewShellVariableResolver(testEnv)
20322163
2033โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2164+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
20342165 require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
20352166
20362167 require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
@@ -2067,7 +2198,7 @@ func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T)
20672198 })
20682199 resolver := NewShellVariableResolver(testEnv)
20692200
2070โˆ’ err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
2201+ err := cfg.configureProviders(context.Background(), testStore(cfg), testEnv, resolver, knownProviders)
20712202 require.NoError(t, err)
20722203
20732204 require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
internal/config/store.go+1โˆ’1
@@ -783,7 +783,7 @@ func (s *ConfigStore) reloadFromDiskLocked(ctx context.Context) error {
783783 return fmt.Errorf("failed to load providers during reload: %w", err)
784784 }
785785
786โˆ’ if err := cfg.configureProviders(s, env, resolver, providers); err != nil {
786+ if err := cfg.configureProviders(ctx, s, env, resolver, providers); err != nil {
787787 return fmt.Errorf("failed to configure providers during reload: %w", err)
788788 }
789789
internal/discover/discover.gonew file+135
@@ -0,0 +1,135 @@
1+package discover
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "net/http"
9+ "strings"
10+ "time"
11+
12+ "charm.land/catwalk/pkg/catwalk"
13+)
14+
15+// httpClient is shared across all discovery and enrichment calls. It
16+// has a reasonable timeout so individual requests cannot block forever
17+// even if the caller forgets to set a context deadline.
18+var httpClient = &http.Client{Timeout: 10 * time.Second}
19+
20+// doRequest builds and executes an authenticated HTTP request using the
21+// shared client. It resolves variable references in the base URL, API
22+// key, and extra headers via the provided Resolver. The path is joined
23+// to the base URL with proper slash handling.
24+func doRequest(ctx context.Context, method, baseURL, path, apiKey string, extraHeaders map[string]string, resolver Resolver, body any) (*http.Response, error) {
25+ resolvedBase, _ := resolver.ResolveValue(baseURL)
26+ resolvedKey, _ := resolver.ResolveValue(apiKey)
27+
28+ url := strings.TrimRight(resolvedBase, "/") + "/" + strings.TrimLeft(path, "/")
29+
30+ var reqBody *bytes.Reader
31+ if body != nil {
32+ data, err := json.Marshal(body)
33+ if err != nil {
34+ return nil, fmt.Errorf("marshaling request body: %w", err)
35+ }
36+ reqBody = bytes.NewReader(data)
37+ }
38+
39+ var req *http.Request
40+ var err error
41+ if reqBody != nil {
42+ req, err = http.NewRequestWithContext(ctx, method, url, reqBody)
43+ } else {
44+ req, err = http.NewRequestWithContext(ctx, method, url, nil)
45+ }
46+ if err != nil {
47+ return nil, err
48+ }
49+
50+ if reqBody != nil {
51+ req.Header.Set("Content-Type", "application/json")
52+ }
53+ if resolvedKey != "" {
54+ req.Header.Set("Authorization", "Bearer "+resolvedKey)
55+ }
56+ for k, v := range extraHeaders {
57+ resolved, err := resolver.ResolveValue(v)
58+ if err != nil || resolved == "" {
59+ continue
60+ }
61+ req.Header.Set(k, resolved)
62+ }
63+
64+ return httpClient.Do(req)
65+}
66+
67+// Config holds the provider configuration needed for model discovery.
68+type Config struct {
69+ ID string
70+ BaseURL string
71+ APIKey string
72+ ExtraHeaders map[string]string
73+ // Existing models from config โ€” IDs present in this list are skipped
74+ // during discovery (user-specified models win).
75+ ExistingModels []catwalk.Model
76+}
77+
78+// Resolver resolves variable references (e.g. $ENV_VAR) in config values.
79+type Resolver interface {
80+ ResolveValue(val string) (string, error)
81+}
82+
83+type modelsResponse struct {
84+ Data []struct {
85+ ID string `json:"id"`
86+ Object string `json:"object"`
87+ Created int64 `json:"created"`
88+ OwnedBy string `json:"owned_by"`
89+ } `json:"data"`
90+}
91+
92+// DiscoverModels fetches available models from the provider's /models endpoint.
93+// It uses the provided context for cancellation and timeout; callers should set
94+// a deadline (e.g. context.WithTimeout) to avoid blocking indefinitely.
95+// Models whose IDs already appear in cfg.ExistingModels are skipped โ€”
96+// user-specified models take precedence.
97+func DiscoverModels(ctx context.Context, cfg Config, resolver Resolver) ([]catwalk.Model, error) {
98+ resp, err := doRequest(ctx, http.MethodGet, cfg.BaseURL, "/models", cfg.APIKey, cfg.ExtraHeaders, resolver, nil)
99+ if err != nil {
100+ return nil, fmt.Errorf("discover models for provider %s: %w", cfg.ID, err)
101+ }
102+ defer resp.Body.Close()
103+
104+ if resp.StatusCode != http.StatusOK {
105+ return nil, fmt.Errorf("discover models for provider %s: %s", cfg.ID, resp.Status)
106+ }
107+
108+ var modelsResp modelsResponse
109+ if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil {
110+ return nil, fmt.Errorf("discover models for provider %s: %w", cfg.ID, err)
111+ }
112+
113+ // Build set of existing model IDs to skip.
114+ existing := make(map[string]struct{}, len(cfg.ExistingModels))
115+ for _, m := range cfg.ExistingModels {
116+ existing[m.ID] = struct{}{}
117+ }
118+
119+ // Start with user-specified models.
120+ result := make([]catwalk.Model, len(cfg.ExistingModels))
121+ copy(result, cfg.ExistingModels)
122+
123+ // Append discovered models not already in the list.
124+ for _, e := range modelsResp.Data {
125+ if _, ok := existing[e.ID]; ok {
126+ continue
127+ }
128+ result = append(result, catwalk.Model{
129+ ID: e.ID,
130+ Name: e.ID,
131+ })
132+ }
133+
134+ return result, nil
135+}
internal/discover/discover_test.gonew file+202
@@ -0,0 +1,202 @@
1+package discover
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "charm.land/catwalk/pkg/catwalk"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestDiscoverModels(t *testing.T) {
14+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ require.Equal(t, "/v1/models", r.URL.Path)
16+ require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
17+
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "object": "list",
21+ "data": [
22+ {"id": "model-a", "object": "model", "owned_by": "org"},
23+ {"id": "model-b", "object": "model", "owned_by": "org"}
24+ ]
25+ }`))
26+ }))
27+ defer server.Close()
28+
29+ cfg := Config{
30+ ID: "test",
31+ BaseURL: server.URL + "/v1",
32+ APIKey: "test-key",
33+ }
34+
35+ models, err := DiscoverModels(context.Background(), cfg, &mockResolver{})
36+ require.NoError(t, err)
37+ require.Len(t, models, 2)
38+ require.Equal(t, "model-a", models[0].ID)
39+ require.Equal(t, "model-b", models[1].ID)
40+}
41+
42+func TestDiscoverModels_ExistingModelsWin(t *testing.T) {
43+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
44+ w.Header().Set("Content-Type", "application/json")
45+ _, _ = w.Write([]byte(`{
46+ "data": [
47+ {"id": "model-a", "object": "model"},
48+ {"id": "model-b", "object": "model"}
49+ ]
50+ }`))
51+ }))
52+ defer server.Close()
53+
54+ cfg := Config{
55+ ID: "test",
56+ BaseURL: server.URL + "/v1",
57+ APIKey: "test-key",
58+ ExistingModels: []catwalk.Model{
59+ {ID: "model-a", Name: "My Custom Name", ContextWindow: 200000, CanReason: true},
60+ },
61+ }
62+
63+ models, err := DiscoverModels(context.Background(), cfg, &mockResolver{})
64+ require.NoError(t, err)
65+ require.Len(t, models, 2)
66+
67+ require.Equal(t, "model-a", models[0].ID)
68+ require.Equal(t, "My Custom Name", models[0].Name)
69+ require.Equal(t, int64(200000), models[0].ContextWindow)
70+ require.True(t, models[0].CanReason)
71+
72+ require.Equal(t, "model-b", models[1].ID)
73+ require.Equal(t, "model-b", models[1].Name)
74+}
75+
76+func TestDiscoverModels_HTTPError(t *testing.T) {
77+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+ w.WriteHeader(http.StatusInternalServerError)
79+ }))
80+ defer server.Close()
81+
82+ cfg := Config{
83+ ID: "test",
84+ BaseURL: server.URL + "/v1",
85+ APIKey: "test-key",
86+ }
87+
88+ models, err := DiscoverModels(context.Background(), cfg, &mockResolver{})
89+ require.Error(t, err)
90+ require.Nil(t, models)
91+}
92+
93+func TestDiscoverModels_ExtraHeaders(t *testing.T) {
94+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ require.Equal(t, "custom-value", r.Header.Get("X-Custom"))
96+ w.Header().Set("Content-Type", "application/json")
97+ _, _ = w.Write([]byte(`{"data": [{"id": "m1", "object": "model"}]}`))
98+ }))
99+ defer server.Close()
100+
101+ cfg := Config{
102+ ID: "test",
103+ BaseURL: server.URL + "/v1",
104+ APIKey: "test-key",
105+ ExtraHeaders: map[string]string{
106+ "X-Custom": "custom-value",
107+ },
108+ }
109+
110+ models, err := DiscoverModels(context.Background(), cfg, &mockResolver{})
111+ require.NoError(t, err)
112+ require.Len(t, models, 1)
113+}
114+
115+type mockResolver struct{}
116+
117+func (m *mockResolver) ResolveValue(val string) (string, error) { return val, nil }
118+
119+type envResolver struct {
120+ env map[string]string
121+}
122+
123+func (e *envResolver) ResolveValue(val string) (string, error) {
124+ if v, ok := e.env[val]; ok {
125+ return v, nil
126+ }
127+ return val, nil
128+}
129+
130+func TestDiscoverModels_ResolvesShellVariables(t *testing.T) {
131+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+ require.Equal(t, "Bearer resolved-key", r.Header.Get("Authorization"))
133+ require.Equal(t, "resolved-header", r.Header.Get("X-Custom"))
134+ w.Header().Set("Content-Type", "application/json")
135+ _, _ = w.Write([]byte(`{"data": [{"id": "m1", "object": "model"}]}`))
136+ }))
137+ defer server.Close()
138+
139+ cfg := Config{
140+ ID: "test",
141+ BaseURL: server.URL + "/v1",
142+ APIKey: "$MY_API_KEY",
143+ ExtraHeaders: map[string]string{
144+ "X-Custom": "$MY_HEADER",
145+ },
146+ }
147+
148+ resolver := &envResolver{env: map[string]string{
149+ "$MY_API_KEY": "resolved-key",
150+ "$MY_HEADER": "resolved-header",
151+ }}
152+
153+ models, err := DiscoverModels(context.Background(), cfg, resolver)
154+ require.NoError(t, err)
155+ require.Len(t, models, 1)
156+}
157+
158+func TestDiscoverModels_SkipsEmptyExtraHeaders(t *testing.T) {
159+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160+ require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
161+ require.Empty(t, r.Header.Get("X-Empty"))
162+ w.Header().Set("Content-Type", "application/json")
163+ _, _ = w.Write([]byte(`{"data": [{"id": "m1", "object": "model"}]}`))
164+ }))
165+ defer server.Close()
166+
167+ cfg := Config{
168+ ID: "test",
169+ BaseURL: server.URL + "/v1",
170+ APIKey: "test-key",
171+ ExtraHeaders: map[string]string{
172+ "X-Empty": "$UNSET_VAR",
173+ },
174+ }
175+
176+ resolver := &envResolver{env: map[string]string{
177+ "$UNSET_VAR": "",
178+ }}
179+
180+ models, err := DiscoverModels(context.Background(), cfg, resolver)
181+ require.NoError(t, err)
182+ require.Len(t, models, 1)
183+}
184+
185+func TestDiscoverModels_NoAuthWhenNoAPIKey(t *testing.T) {
186+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187+ require.Empty(t, r.Header.Get("Authorization"))
188+ w.Header().Set("Content-Type", "application/json")
189+ _, _ = w.Write([]byte(`{"data": [{"id": "m1", "object": "model"}]}`))
190+ }))
191+ defer server.Close()
192+
193+ cfg := Config{
194+ ID: "test",
195+ BaseURL: server.URL + "/v1",
196+ APIKey: "",
197+ }
198+
199+ models, err := DiscoverModels(context.Background(), cfg, &mockResolver{})
200+ require.NoError(t, err)
201+ require.Len(t, models, 1)
202+}
internal/discover/enricher.gonew file+44
@@ -0,0 +1,44 @@
1+package discover
2+
3+import (
4+ "context"
5+
6+ "charm.land/catwalk/pkg/catwalk"
7+)
8+
9+// Enricher fills in model metadata (context window, max tokens, pricing,
10+// etc.) for discovered models. Providers that expose richer endpoints
11+// beyond /v1/models can register an Enricher to populate fields that the
12+// standard listing endpoint omits.
13+type Enricher interface {
14+ // EnrichModels takes a slice of bare discovered models and returns
15+ // them with metadata populated. Implementations should preserve
16+ // existing non-zero fields (user overrides take precedence).
17+ EnrichModels(ctx context.Context, cfg Config, resolver Resolver, models []catwalk.Model) ([]catwalk.Model, error)
18+}
19+
20+// enrichers maps provider type strings to their enrichment
21+// implementations. Each enricher self-registers via init() so that
22+// adding a new provider requires only a new file โ€” no changes to
23+// load.go or any existing enricher.
24+var enrichers = map[string]Enricher{}
25+
26+// RegisterEnricher registers an Enricher for the given provider type.
27+// Called from init() in each enricher implementation file.
28+func RegisterEnricher(providerType string, e Enricher) {
29+ enrichers[providerType] = e
30+}
31+
32+// GetEnricher returns the Enricher for the given provider type, or nil
33+// if no enricher is registered.
34+func GetEnricher(providerType string) Enricher {
35+ return enrichers[providerType]
36+}
37+
38+// IsKnownCustomProvider reports whether the given provider type has a
39+// registered enricher. This signals that the provider type is
40+// recognized and speaks OpenAI-compat protocol, without exposing the
41+// enricher itself to callers that only need the compatibility check.
42+func IsKnownCustomProvider(providerType string) bool {
43+ return enrichers[providerType] != nil
44+}