diff --git a/.env.template b/.env.template index 71f7f446b..94d032bbd 100644 --- a/.env.template +++ b/.env.template @@ -308,8 +308,14 @@ # allowlist: expose only the configured models for providers that define a list, and skip their upstream /models calls. # merge: keep the upstream inventory and add configured models it does not list, # so models a provider serves without listing them (preview or unlisted IDs) stay routable. +# List entries containing * or ? are glob patterns matched case-insensitively against +# upstream model IDs (* also matches /, so *:free matches deepseek/deepseek-r1:free); +# * alone takes the whole upstream inventory. A list with at least one pattern always +# queries upstream /models in every mode and falls back to its exact entries only when +# the upstream cannot supply an inventory. # CONFIGURED_PROVIDER_MODELS_MODE=fallback # Examples: OPENROUTER_MODELS=..., OPENROUTER_EU_MODELS=..., AZURE_MODELS=..., VLLM_MODELS=... +# Glob examples: OPENROUTER_MODELS="*:free,*", VLLM_MODELS="*-instruct,meta-llama/Llama-3.1-8B-Instruct" # Narrow a provider's model inventory to what you actually want routable. Applied # to the final inventory, so it also narrows models added by _MODELS. @@ -712,8 +718,10 @@ # OpenRouter (default base URL: https://openrouter.ai/api/v1) # OPENROUTER_API_KEY=sk-or-... # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 -# Optional configured model list; see CONFIGURED_PROVIDER_MODELS_MODE below +# Optional configured model list; see CONFIGURED_PROVIDER_MODELS_MODE below. +# Entries with * or ? are globs resolved against upstream /models in every mode. # OPENROUTER_MODELS=openai/gpt-oss-120b,anthropic/claude-sonnet-4 +# OPENROUTER_MODELS=*:free,anthropic/claude-sonnet-4 # Free models only; see _MODEL_FILTER_* above # OPENROUTER_MODEL_FILTER_INCLUDE=*:free # OPENROUTER_SITE_URL=https://gomodel.enterpilot.io diff --git a/config/config.example.yaml b/config/config.example.yaml index 1a177aaa6..7553d5191 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -33,7 +33,7 @@ models: enabled_by_default: true # env: MODELS_ENABLED_BY_DEFAULT; when false, models stay unavailable until an access override allows one or more user paths keep_only_aliases_at_models_endpoint: false # env: KEEP_ONLY_ALIASES_AT_MODELS_ENDPOINT; hide provider models from GET /v1/models and expose only enabled virtual models unqualified_model_ids_at_models_endpoint: false # env: UNQUALIFIED_MODEL_IDS_AT_MODELS_ENDPOINT; list bare model IDs (gpt-5) instead of provider-qualified ones (openai/gpt-5). Caveat: when two providers expose the same model ID only the provider an unqualified request routes to (the first registered one) is listed; pin a name with a virtual model (source gpt-5 -> target azure/gpt-5) - configured_provider_models_mode: "fallback" # env: CONFIGURED_PROVIDER_MODELS_MODE; "fallback" uses configured lists only when upstream /models is unavailable/empty, "allowlist" exposes only configured models and skips upstream /models for configured lists, "merge" adds configured models on top of the upstream inventory (for models a provider serves but does not list) + configured_provider_models_mode: "fallback" # env: CONFIGURED_PROVIDER_MODELS_MODE; "fallback" uses configured lists only when upstream /models is unavailable/empty, "allowlist" exposes only configured models and skips upstream /models for configured lists, "merge" adds configured models on top of the upstream inventory (for models a provider serves but does not list). Glob patterns in a list (entries containing * or ?, e.g. "*:free") bypass mode handling: they always resolve against a live upstream /models response in every mode # Tagging based on headers: label every request from the listed headers. Labels # are recorded in usage tracking and audit logs. A header value can carry several @@ -691,13 +691,20 @@ providers: # In fallback mode (default), this list is used only if upstream /models is # unavailable or empty. In allowlist mode, only these models are exposed and # upstream /models is skipped for this provider. - # You can also set OPENROUTER_MODELS="openai/gpt-oss-120b,anthropic/claude-sonnet-4". + # Entries containing `*` or `?` are glob patterns, matched case-insensitively + # against the upstream model IDs (`*` also matches `/`, so "*:free" matches + # "deepseek/deepseek-r1:free"); "*" alone takes the whole upstream inventory. + # A list with at least one pattern always queries upstream /models in every + # mode and resolves to the matching models plus the exact entries; when the + # upstream cannot supply an inventory, only the exact entries are used. + # You can also set OPENROUTER_MODELS="*:free,anthropic/claude-sonnet-4". # openrouter: # type: "openrouter" # base_url: "https://openrouter.ai/api/v1" # api_key: "${OPENROUTER_API_KEY}" # models: - # - openai/gpt-oss-120b + # - "*:free" # every free-tier model upstream lists + # - openai/gpt-oss-120b # exact entries work as before # - anthropic/claude-sonnet-4 # # Narrow the inventory to what should be routable. Patterns are globs # # matched case-insensitively against the raw model ID; `*` also matches `/`, diff --git a/config/models.go b/config/models.go index 7c99ef895..1dc52602d 100644 --- a/config/models.go +++ b/config/models.go @@ -24,11 +24,17 @@ type ModelsConfig struct { // ConfiguredProviderModelsMode controls how providers..models and // provider *_MODELS env vars affect the provider model inventory. // Supported values: "fallback", "allowlist", "merge". Default: "fallback". + // Entries may contain glob patterns ("*:free", "*"): a list with at least + // one pattern always queries the upstream /models endpoint and resolves + // patterns against it, in every mode. ConfiguredProviderModelsMode ConfiguredProviderModelsMode `yaml:"configured_provider_models_mode" env:"CONFIGURED_PROVIDER_MODELS_MODE"` } // ConfiguredProviderModelsMode controls how explicitly configured provider -// model lists are applied to the discovered model inventory. +// model lists are applied to the discovered model inventory. Glob patterns +// (entries containing `*` or `?`) in a list bypass mode handling: they always +// resolve against the upstream /models inventory and drop to the exact +// entries when the upstream cannot provide one. type ConfiguredProviderModelsMode string const ( @@ -36,7 +42,8 @@ const ( // upstream /models call fails or returns nothing. ConfiguredProviderModelsModeFallback ConfiguredProviderModelsMode = "fallback" // ConfiguredProviderModelsModeAllowlist exposes only the configured models - // and skips the upstream /models call. + // and skips the upstream /models call. The skip does not apply to lists + // containing glob patterns, which need the upstream inventory to resolve. ConfiguredProviderModelsModeAllowlist ConfiguredProviderModelsMode = "allowlist" // ConfiguredProviderModelsModeMerge unions the upstream inventory with the // configured models, so models a provider serves but does not list stay diff --git a/docs/advanced/config-yaml.mdx b/docs/advanced/config-yaml.mdx index 2b8b08f19..e132a5af2 100644 --- a/docs/advanced/config-yaml.mdx +++ b/docs/advanced/config-yaml.mdx @@ -45,6 +45,26 @@ configured models for providers that define a list and skip their upstream inventory — useful for models a provider serves but does not include in its `/models` listing. +Entries containing `*` or `?` are glob patterns, matched case-insensitively +against the upstream model IDs (`*` also matches `/`, so `*:free` matches +`deepseek/deepseek-r1:free`); `*` alone takes the whole upstream inventory. +A list with at least one pattern always resolves against a live upstream +`/models` response in every mode — the allowlist skip applies only to +exact-only lists — and exposes the matching upstream models plus the exact +entries. When the upstream cannot supply an inventory, only the exact entries +are used; a pattern is never published as a literal model ID. Lists without +patterns behave per mode as described above. + +```yaml +providers: + openrouter: + type: openrouter + api_key: "${OPENROUTER_API_KEY}" + models: + - "*:free" # every free-tier model upstream lists + - anthropic/claude-sonnet-4 # exact entries work as before +``` + ## Filtering a provider's models `model_filter` narrows a provider's inventory to the models you actually want diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 8d7e6e7d6..9e21e4c85 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -461,6 +461,17 @@ providers that define a list and skip their upstream `/models` calls. YAML `providers..models` provides the same model-list input for named provider blocks. +List entries containing `*` or `?` are glob patterns, matched +case-insensitively against the upstream model IDs (`*` also matches `/`, so +`*:free` matches `deepseek/deepseek-r1:free`); `*` alone takes the whole +upstream inventory. A list with at least one pattern always queries upstream +`/models` in every mode — the allowlist skip applies only to exact-only lists — +and resolves to the matching upstream models plus the exact entries. When the +upstream cannot supply an inventory, only the exact entries are used; a pattern +is never published as a literal model ID. For example, +`OPENROUTER_MODELS="*:free,anthropic/claude-sonnet-4"` exposes every free-tier +model OpenRouter lists plus that one exact model. + `GET /v1/models` lists provider-qualified IDs (`openai/gpt-5`) by default. Set `UNQUALIFIED_MODEL_IDS_AT_MODELS_ENDPOINT=true` (YAML: `models.unqualified_model_ids_at_models_endpoint`) to list bare model IDs diff --git a/internal/providers/configured_models.go b/internal/providers/configured_models.go index f2e33b7a8..0ad3b3c2e 100644 --- a/internal/providers/configured_models.go +++ b/internal/providers/configured_models.go @@ -13,9 +13,12 @@ import ( type configuredProviderModelsApplyReason string const ( - configuredProviderModelsNotApplied configuredProviderModelsApplyReason = "" - configuredProviderModelsAllowlist configuredProviderModelsApplyReason = "allowlist" - configuredProviderModelsMerge configuredProviderModelsApplyReason = "merge" + configuredProviderModelsNotApplied configuredProviderModelsApplyReason = "" + configuredProviderModelsAllowlist configuredProviderModelsApplyReason = "allowlist" + configuredProviderModelsMerge configuredProviderModelsApplyReason = "merge" + // configuredProviderModelsWildcard means the configured list contained glob + // patterns and they were resolved against a healthy upstream inventory. + configuredProviderModelsWildcard configuredProviderModelsApplyReason = "wildcard" configuredProviderModelsUpstreamError configuredProviderModelsApplyReason = "upstream_error" // configuredProviderModelsUpstreamUnlisted means the provider has no model // listing endpoint (404/405 on /models). Servers that only expose a single @@ -64,6 +67,28 @@ func applyConfiguredProviderModels( } mode = config.ResolveConfiguredProviderModelsMode(mode) + + // A list containing glob patterns resolves against the real upstream + // inventory in every mode: patterns are meaningless without it. When the + // upstream cannot supply one, only the exact entries survive — a pattern + // is never published as a literal model ID. + if hasModelPattern(configuredModels) { + exact, patterns := splitConfiguredModels(configuredModels) + if modelListingUnsupported(upstreamErr) { + return configuredProviderModelsResponse(providerName, providerType, exact, upstream, fallbackCreated), configuredProviderModelsUpstreamUnlisted + } + if upstreamErr != nil { + return configuredProviderModelsResponse(providerName, providerType, exact, upstream, fallbackCreated), configuredProviderModelsUpstreamError + } + if upstream == nil { + return configuredProviderModelsResponse(providerName, providerType, exact, upstream, fallbackCreated), configuredProviderModelsUpstreamNil + } + if len(upstream.Data) == 0 { + return configuredProviderModelsResponse(providerName, providerType, exact, upstream, fallbackCreated), configuredProviderModelsUpstreamEmpty + } + return wildcardConfiguredModelsResponse(providerName, providerType, exact, patterns, upstream, fallbackCreated), configuredProviderModelsWildcard + } + if mode == config.ConfiguredProviderModelsModeAllowlist { return configuredProviderModelsResponse(providerName, providerType, configuredModels, upstream, fallbackCreated), configuredProviderModelsAllowlist } @@ -159,6 +184,90 @@ func mergeConfiguredProviderModelsResponse(providerName, providerType string, co } } +// hasModelPattern reports whether any configured entry is a glob pattern. +// Entries containing `*` or `?` are patterns; anything else is an exact model +// ID (substring matching is written `*free*`, not `free`). +func hasModelPattern(models []string) bool { + for _, model := range models { + if strings.ContainsAny(model, "*?") { + return true + } + } + return false +} + +// splitConfiguredModels separates a configured model list into exact model IDs +// and glob patterns, preserving the configured order within each group. +func splitConfiguredModels(models []string) (exact []string, patterns []string) { + for _, model := range models { + if strings.ContainsAny(model, "*?") { + patterns = append(patterns, model) + continue + } + exact = append(exact, model) + } + return exact, patterns +} + +// wildcardConfiguredModelsResponse resolves glob patterns against a healthy +// upstream inventory: upstream entries matching at least one pattern stay in +// upstream order with their metadata, then the exact configured entries follow +// in configured order — reusing the upstream entry when listed there, else +// synthesized. The registry only ever sees resolved model IDs, never patterns. +func wildcardConfiguredModelsResponse(providerName, providerType string, exact, patterns []string, upstream *core.ModelsResponse, fallbackCreated int64) *core.ModelsResponse { + byID := make(map[string]core.Model, len(upstream.Data)) + data := make([]core.Model, 0, len(upstream.Data)+len(exact)) + appended := make(map[string]struct{}, len(upstream.Data)+len(exact)) + for _, model := range upstream.Data { + modelID := strings.TrimSpace(model.ID) + if modelID == "" { + continue + } + if _, ok := byID[modelID]; ok { + // Upstream listings can repeat an ID (also via whitespace + // variants that normalize to one); the first entry wins. + continue + } + // Registry lookups trim requested IDs, so the retained entry must be + // indexed under the same normalized key it deduplicates by. + model.ID = modelID + byID[modelID] = model + if matchesAnyGlob(patterns, modelID) { + appended[modelID] = struct{}{} + data = append(data, model) + } + } + + owner := configuredModelOwner(providerName, providerType) + created := normalizeFallbackCreated(fallbackCreated) + for _, modelID := range exact { + if _, ok := appended[modelID]; ok { + continue + } + appended[modelID] = struct{}{} + model, ok := byID[modelID] + if !ok { + model = synthesizedConfiguredModel(modelID, owner, created) + } else { + if strings.TrimSpace(model.Object) == "" { + model.Object = "model" + } + if strings.TrimSpace(model.OwnedBy) == "" { + model.OwnedBy = owner + } + if model.Created == 0 { + model.Created = created + } + } + data = append(data, model) + } + + return &core.ModelsResponse{ + Object: "list", + Data: data, + } +} + func configuredProviderModelsResponse(providerName, providerType string, configuredModels []string, upstream *core.ModelsResponse, fallbackCreated int64) *core.ModelsResponse { byID := make(map[string]core.Model) if upstream != nil { diff --git a/internal/providers/configured_models_test.go b/internal/providers/configured_models_test.go index 6cb2e24f6..abe97eb21 100644 --- a/internal/providers/configured_models_test.go +++ b/internal/providers/configured_models_test.go @@ -8,6 +8,7 @@ import ( "github.com/enterpilot/gomodel/config" "github.com/enterpilot/gomodel/internal/core" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -123,3 +124,223 @@ func TestApplyConfiguredProviderModels_MissingModelsEndpointIsAuthoritative(t *t }) } } + +// wildcardTestUpstream returns a healthy upstream inventory for the pattern +// resolution tests. Metadata (OwnedBy/Created) marks entries that must survive +// pattern resolution untouched. +func wildcardTestUpstream() *core.ModelsResponse { + return &core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + {ID: "openai/gpt-4o:free", Object: "model", OwnedBy: "upstream", Created: 42}, + {ID: "deepseek/deepseek-r1:free", Object: "model", OwnedBy: "upstream", Created: 43}, + {ID: "openai/gpt-4o", Object: "model", OwnedBy: "upstream", Created: 44}, + {ID: "meta/llama-3-free", Object: "model", OwnedBy: "upstream", Created: 45}, + }, + } +} + +func modelIDs(resp *core.ModelsResponse) []string { + ids := make([]string, 0, len(resp.Data)) + for _, model := range resp.Data { + ids = append(ids, model.ID) + } + return ids +} + +func TestApplyConfiguredProviderModels_WildcardExpandsPatterns(t *testing.T) { + tests := []struct { + name string + configured []string + wantIDs []string + }{ + { + name: "suffix glob", + configured: []string{"*:free"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free"}, + }, + { + name: "suffix glob with exact extra", + configured: []string{"*:free", "extra-model"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free", "extra-model"}, + }, + { + name: "prefix glob", + configured: []string{"*-free"}, + wantIDs: []string{"meta/llama-3-free"}, + }, + { + name: "substring glob", + configured: []string{"*free*"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free", "meta/llama-3-free"}, + }, + { + name: "glob is case-insensitive", + configured: []string{"*:FREE"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free"}, + }, + { + name: "question mark glob", + configured: []string{"openai/gpt-4?"}, + wantIDs: []string{"openai/gpt-4o"}, + }, + { + name: "star alone unions upstream with exact extras", + configured: []string{"*", "extra-model"}, + wantIDs: []string{ + "openai/gpt-4o:free", + "deepseek/deepseek-r1:free", + "openai/gpt-4o", + "meta/llama-3-free", + "extra-model", + }, + }, + { + name: "exact entry already matched by pattern stays in upstream order", + configured: []string{"*:free", "deepseek/deepseek-r1:free"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free"}, + }, + { + name: "exact entry listed upstream but not matched is appended with upstream metadata", + configured: []string{"*:free", "openai/gpt-4o"}, + wantIDs: []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free", "openai/gpt-4o"}, + }, + { + name: "pattern matching nothing still keeps exact entries", + configured: []string{"anthropic/*", "extra-model"}, + wantIDs: []string{"extra-model"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, reason := applyConfiguredProviderModels( + "test", + "test-type", + config.ConfiguredProviderModelsModeFallback, + tt.configured, + wildcardTestUpstream(), + nil, + 123, + ) + require.Equal(t, configuredProviderModelsWildcard, reason) + require.NotNil(t, resp) + assert.Equal(t, tt.wantIDs, modelIDs(resp)) + + for _, model := range resp.Data { + assert.NotContains(t, model.ID, "*", "a pattern must never be published as a literal model ID") + assert.NotContains(t, model.ID, "?", "a pattern must never be published as a literal model ID") + } + }) + } +} + +func TestApplyConfiguredProviderModels_WildcardPreservesUpstreamMetadata(t *testing.T) { + resp, reason := applyConfiguredProviderModels( + "test", + "test-type", + config.ConfiguredProviderModelsModeFallback, + []string{"*:free", "openai/gpt-4o", "extra-model"}, + wildcardTestUpstream(), + nil, + 123, + ) + require.Equal(t, configuredProviderModelsWildcard, reason) + require.NotNil(t, resp) + require.Len(t, resp.Data, 4) + + // Matched upstream entries keep their metadata. + assert.Equal(t, int64(42), resp.Data[0].Created) + assert.Equal(t, "upstream", resp.Data[0].OwnedBy) + assert.Equal(t, int64(43), resp.Data[1].Created) + + // An exact entry listed upstream reuses the upstream entry, trimmed and + // with the existing fixups — not a synthesized one. + require.Equal(t, "openai/gpt-4o", resp.Data[2].ID) + assert.Equal(t, int64(44), resp.Data[2].Created) + assert.Equal(t, "upstream", resp.Data[2].OwnedBy) + + // An exact entry the upstream does not list is synthesized. + require.Equal(t, "extra-model", resp.Data[3].ID) + assert.Equal(t, "model", resp.Data[3].Object) + assert.Equal(t, "test-type", resp.Data[3].OwnedBy) + assert.Equal(t, int64(123), resp.Data[3].Created) +} + +// A list with patterns resolves identically in every mode against a healthy +// upstream: pattern handling upgrades the list regardless of +// configured_provider_models_mode. +func TestApplyConfiguredProviderModels_WildcardIsModeIndependent(t *testing.T) { + for _, mode := range []config.ConfiguredProviderModelsMode{ + config.ConfiguredProviderModelsModeFallback, + config.ConfiguredProviderModelsModeAllowlist, + config.ConfiguredProviderModelsModeMerge, + } { + t.Run(string(mode), func(t *testing.T) { + resp, reason := applyConfiguredProviderModels( + "test", + "test-type", + mode, + []string{"*:free", "extra-model"}, + wildcardTestUpstream(), + nil, + 123, + ) + require.Equal(t, configuredProviderModelsWildcard, reason) + require.NotNil(t, resp) + assert.Equal(t, []string{"openai/gpt-4o:free", "deepseek/deepseek-r1:free", "extra-model"}, modelIDs(resp)) + }) + } +} + +// When the upstream cannot supply an inventory, patterns are unresolvable and +// dropped with the existing fallback reasons — only the exact entries survive, +// and no pattern is ever synthesized as a literal model ID. +func TestApplyConfiguredProviderModels_WildcardFallsBackToExactEntries(t *testing.T) { + notFound := core.MarkModelListingUnsupported(core.ParseProviderError("openai", http.StatusNotFound, nil, nil)) + tests := []struct { + name string + upstream *core.ModelsResponse + err error + wantReason configuredProviderModelsApplyReason + }{ + {name: "error", err: errors.New("upstream down"), wantReason: configuredProviderModelsUpstreamError}, + {name: "unlisted", err: notFound, wantReason: configuredProviderModelsUpstreamUnlisted}, + {name: "nil", wantReason: configuredProviderModelsUpstreamNil}, + {name: "empty", upstream: &core.ModelsResponse{Object: "list"}, wantReason: configuredProviderModelsUpstreamEmpty}, + } + for _, mode := range []config.ConfiguredProviderModelsMode{ + config.ConfiguredProviderModelsModeFallback, + config.ConfiguredProviderModelsModeAllowlist, + config.ConfiguredProviderModelsModeMerge, + } { + for _, tt := range tests { + t.Run(string(mode)+"/"+tt.name, func(t *testing.T) { + resp, reason := applyConfiguredProviderModels( + "test", + "test-type", + mode, + []string{"*:free", "exact-model"}, + tt.upstream, + tt.err, + 123, + ) + require.Equal(t, tt.wantReason, reason) + require.NotNil(t, resp) + require.Equal(t, []string{"exact-model"}, modelIDs(resp)) + }) + } + } +} + +func TestHasModelPattern(t *testing.T) { + assert.False(t, hasModelPattern(nil)) + assert.False(t, hasModelPattern([]string{"gpt-4o", "free"})) + assert.True(t, hasModelPattern([]string{"gpt-4o", "*:free"})) + assert.True(t, hasModelPattern([]string{"gpt-4?"})) +} + +func TestSplitConfiguredModels(t *testing.T) { + exact, patterns := splitConfiguredModels([]string{"gpt-4o", "*:free", "whisper-1", "meta/*"}) + assert.Equal(t, []string{"gpt-4o", "whisper-1"}, exact) + assert.Equal(t, []string{"*:free", "meta/*"}, patterns) +} diff --git a/internal/providers/registry_cache_test.go b/internal/providers/registry_cache_test.go index 754cb424b..6eeb728e5 100644 --- a/internal/providers/registry_cache_test.go +++ b/internal/providers/registry_cache_test.go @@ -506,6 +506,58 @@ func TestCacheFile(t *testing.T) { _, err = os.Stat(cacheFile) require.False(t, os.IsNotExist(err)) }) + + // A pattern list re-expands against the cached inventory on load: cached + // entries matching a pattern and the exact entries are published, cached + // non-matches drop, and no pattern leaks through as a literal model ID. + t.Run("LoadFromCacheConfiguredModelPatternsReexpandAgainstCachedInventory", func(t *testing.T) { + tmpDir := t.TempDir() + cacheFile := filepath.Join(tmpDir, "models.json") + + modelCache := modelcache.ModelCache{ + UpdatedAt: time.Now().UTC(), + Providers: map[string]modelcache.CachedProvider{ + "openrouter": { + ProviderType: "openrouter", + OwnedBy: "openrouter", + Models: []modelcache.CachedModel{ + {ID: "openai/gpt-4o:free", Created: 123}, + {ID: "deepseek/deepseek-r1:free", Created: 456}, + {ID: "openai/gpt-4o", Created: 789}, + }, + }, + }, + } + data, _ := json.Marshal(modelCache) + err := os.WriteFile(cacheFile, data, 0o644) + require.NoError(t, err) + + registry := NewModelRegistry() + registry.SetCache(modelcache.NewLocalCache(cacheFile)) + registry.SetProviderConfiguredModels("openrouter", []string{"*:free", "extra-model"}) + + mock := ®istryMockProvider{name: "openrouter"} + registry.RegisterProviderWithNameAndType(mock, "openrouter", "openrouter") + + loaded, err := registry.LoadFromCache(context.Background()) + require.NoError(t, err) + require.Equal(t, 3, loaded) + + require.True(t, registry.Supports("openai/gpt-4o:free")) + require.True(t, registry.Supports("deepseek/deepseek-r1:free")) + require.True(t, registry.Supports("extra-model")) + assert.False(t, registry.Supports("openai/gpt-4o"), "cached model not matching any pattern must not load") + assert.False(t, registry.Supports("*:free"), "a pattern must never be published as a literal model ID") + + matched := registry.GetModel("openai/gpt-4o:free") + require.NotNil(t, matched) + assert.Equal(t, int64(123), matched.Model.Created) + assert.Equal(t, "openrouter", matched.Model.OwnedBy, "matched model = %+v, want cached metadata preserved", matched.Model) + + extra := registry.GetModel("extra-model") + require.NotNil(t, extra) + assert.Equal(t, "openrouter", extra.Model.OwnedBy) + }) } func TestInitializeAsync(t *testing.T) { diff --git a/internal/providers/registry_init.go b/internal/providers/registry_init.go index f3aa4dbbc..7ecaa009e 100644 --- a/internal/providers/registry_init.go +++ b/internal/providers/registry_init.go @@ -185,6 +185,8 @@ func (r *ModelRegistry) fetchAllProviderModels( slog.Debug("using configured provider models", attrs...) } else if configuredReason == configuredProviderModelsMerge { slog.Debug("merged configured provider models into upstream inventory", attrs...) + } else if configuredReason == configuredProviderModelsWildcard { + slog.Debug("resolved configured provider model patterns against upstream inventory", attrs...) } else { slog.Warn("using configured provider models", attrs...) } @@ -249,6 +251,8 @@ func (r *ModelRegistry) fetchAllProviderModels( // inventory from configuration — reason is configuredProviderModelsAllowlist // - merge mode overlaid configured models on a healthy upstream // response — reason is configuredProviderModelsMerge + // - configured glob patterns resolved against a healthy upstream + // inventory — reason is configuredProviderModelsWildcard // - the upstream has no /models endpoint, so the configured list is // the whole inventory — reason is configuredProviderModelsUpstreamUnlisted // Fallback cases (configured*UpstreamError, *Nil, *Empty) keep @@ -257,15 +261,17 @@ func (r *ModelRegistry) fetchAllProviderModels( if configuredReason == configuredProviderModelsNotApplied || configuredReason == configuredProviderModelsAllowlist || configuredReason == configuredProviderModelsMerge || + configuredReason == configuredProviderModelsWildcard || configuredReason == configuredProviderModelsUpstreamUnlisted { runtimeUpdate.lastModelFetchSuccessAt = fetchAt } - // Merge and unlisted keep availability signals too: the upstream - // answered, unlike the fallback reasons. For unlisted this also clears - // a startup probe that hit the same missing /models endpoint before - // configured models were known. + // Merge, wildcard, and unlisted keep availability signals too: the + // upstream answered, unlike the fallback reasons. For unlisted this + // also clears a startup probe that hit the same missing /models + // endpoint before configured models were known. if configuredReason == configuredProviderModelsNotApplied || configuredReason == configuredProviderModelsMerge || + configuredReason == configuredProviderModelsWildcard || configuredReason == configuredProviderModelsUpstreamUnlisted { runtimeUpdate.lastAvailabilityCheckAt = fetchAt runtimeUpdate.lastAvailabilityOKAt = fetchAt @@ -427,7 +433,9 @@ func fetchProviderInventory( configuredModels []string, ) (*core.ModelsResponse, configuredProviderModelsApplyReason, time.Time, error) { fetchAt := time.Now().UTC() - if mode == config.ConfiguredProviderModelsModeAllowlist && len(configuredModels) > 0 { + // The allowlist fast-path only applies to exact model lists: glob patterns + // resolve against the real upstream inventory, so /models must be queried. + if mode == config.ConfiguredProviderModelsModeAllowlist && len(configuredModels) > 0 && !hasModelPattern(configuredModels) { resp, reason := applyConfiguredProviderModels( providerName, providerType, diff --git a/internal/providers/registry_test.go b/internal/providers/registry_test.go index cdea37ca7..4377a14b5 100644 --- a/internal/providers/registry_test.go +++ b/internal/providers/registry_test.go @@ -875,6 +875,80 @@ func TestModelRegistry(t *testing.T) { }) } +// A configured list containing glob patterns resolves against a real upstream +// ListModels call even in allowlist mode — the skip-upstream fast-path only +// applies to exact-only lists — and publishes only resolved matches plus the +// exact entries, never the pattern strings. +func TestModelRegistryWildcardConfiguredModels(t *testing.T) { + t.Run("PatternListQueriesUpstreamAndPublishesOnlyMatches", func(t *testing.T) { + registry := NewModelRegistry() + registry.SetConfiguredProviderModelsMode(config.ConfiguredProviderModelsModeAllowlist) + var listCount atomic.Int32 + mock := &countingRegistryMockProvider{ + listCount: &listCount, + registryMockProvider: ®istryMockProvider{ + name: "test", + modelsResponse: &core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + {ID: "openai/gpt-4o:free", Object: "model", OwnedBy: "upstream", Created: 42}, + {ID: "deepseek/deepseek-r1:free", Object: "model", OwnedBy: "upstream", Created: 43}, + {ID: "openai/gpt-4o", Object: "model", OwnedBy: "upstream", Created: 44}, + }, + }, + }, + } + registry.RegisterProviderWithNameAndType(mock, "test", "test-type") + registry.SetProviderConfiguredModels("test", []string{"*:free", "extra-model"}) + + err := registry.Initialize(context.Background()) + require.NoError(t, err) + require.Equal(t, int32(1), listCount.Load(), "a pattern list must query upstream /models even in allowlist mode") + + require.True(t, registry.Supports("openai/gpt-4o:free")) + require.True(t, registry.Supports("deepseek/deepseek-r1:free")) + require.True(t, registry.Supports("extra-model")) + assert.False(t, registry.Supports("openai/gpt-4o"), "unmatched upstream model must not be published") + assert.False(t, registry.Supports("*:free"), "a pattern must never be published as a literal model ID") + + matched := registry.GetModel("openai/gpt-4o:free") + require.NotNil(t, matched) + assert.Equal(t, int64(42), matched.Model.Created) + assert.Equal(t, "upstream", matched.Model.OwnedBy) + + extra := registry.GetModel("extra-model") + require.NotNil(t, extra) + assert.Equal(t, "test-type", extra.Model.OwnedBy) + + snapshots := registry.ProviderRuntimeSnapshots() + require.Len(t, snapshots, 1) + assert.Empty(t, snapshots[0].LastModelFetchError) + assert.NotNil(t, snapshots[0].LastModelFetchSuccessAt) + assert.NotNil(t, snapshots[0].LastAvailabilityOKAt) + }) + + t.Run("PatternListFallsBackToExactEntriesWhenUpstreamFails", func(t *testing.T) { + registry := NewModelRegistry() + mock := ®istryMockProvider{ + name: "test", + err: errors.New("models unavailable"), + } + registry.RegisterProviderWithNameAndType(mock, "test", "test") + registry.SetProviderConfiguredModels("test", []string{"*:free", "exact-model"}) + + err := registry.Initialize(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, registry.ModelCount()) + require.True(t, registry.Supports("exact-model")) + assert.False(t, registry.Supports("*:free"), "a pattern must never be published as a literal model ID") + + snapshots := registry.ProviderRuntimeSnapshots() + require.Len(t, snapshots, 1) + assert.Contains(t, snapshots[0].LastModelFetchError, "models unavailable") + assert.Nil(t, snapshots[0].LastModelFetchSuccessAt) + }) +} + // A provider whose refresh fails keeps serving its previous inventory, marked // stale: models stay resolvable for direct requests, ModelAvailable reports // false so load balancing skips them, and the next successful refresh clears