Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PROVIDER>_MODELS.
Expand Down Expand Up @@ -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 <PROVIDER>_MODEL_FILTER_* above
# OPENROUTER_MODEL_FILTER_INCLUDE=*:free
# OPENROUTER_SITE_URL=https://gomodel.enterpilot.io
Expand Down
13 changes: 10 additions & 3 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `/`,
Expand Down
11 changes: 9 additions & 2 deletions config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,26 @@ type ModelsConfig struct {
// ConfiguredProviderModelsMode controls how providers.<name>.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 (
// ConfiguredProviderModelsModeFallback uses configured models only when the
// 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
Expand Down
20 changes: 20 additions & 0 deletions docs/advanced/config-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,17 @@ providers that define a list and skip their upstream `/models` calls. YAML
`providers.<name>.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
Expand Down
115 changes: 112 additions & 3 deletions internal/providers/configured_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading