From 622686091732645cdf18d10c7367a288c09e7d01 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 15 Oct 2026 21:48:18 +0100 Subject: [PATCH] feat(registry): add requireModelPrefix to disable bare model ids When the flag is true, a model resolves only as provider/model or as a modelAliases id. A bare id returns 404 unknown model. GET /v1/models lists every model in provider/model form and lists no bare ids. The flag defaults to false, so existing deployments keep the sorted-first bare-id collision winner. --- README.md | 1 + llmgateway.go | 5 ++ registry.go | 11 +++- registry_requireprefix_test.go | 115 +++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 registry_requireprefix_test.go diff --git a/README.md b/README.md index b34b432..c4408f3 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,7 @@ config accepts them as YAML, which decodes to the same JSON shape. | `admin` | `*AdminConfig` | `nil` (disabled) | Read-only admin dashboard — see [Admin](#admin). `nil` or `enabled: false` means the `/admin*` routes are not registered at all. | | `metrics` | `*MetricsConfig` | `nil` (disabled) | Prometheus text-exposition endpoint — see [Metrics](#metrics). `nil` or `enabled: false` means the metrics route is not registered at all. | | `modelAliases` | `map[string]string` | `{}` | Operator-defined alias id → target model id — see [Model aliases](#model-aliases). Omitted or empty means no aliases, byte-identical to a gateway built before this field existed. | +| `requireModelPrefix` | `bool` | `false` | When `true`, a model resolves only as `provider/model` or as a `modelAliases` id. A bare id returns 404 `unknown model` and `GET /v1/models` lists no bare ids. `false` keeps the sorted-first bare-id collision winner. | | `modelMeta` | `map[string]*ModelMetaConfig` | `{}` | Per-model context-window and cost overrides, keyed by exact `provider/model` or a bare model id — see [Model metadata](#model-metadata). Omitted or empty means no overrides; every model's metadata still resolves through discovery and the built-in table. | | `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. **Does not cover the three media routes** — see the upgrade note below. | | `maxInFlightBodyRequests` | `int` | `0` (self-tuned) | Caps how many unified/media requests may concurrently be inside their own request-body read+decode step — see [Body-admission limit](#body-admission-limit). `0` self-tunes from the host's own `GOMAXPROCS`; a positive value pins an exact cap instead, clamped to a maximum of 10,000. | diff --git a/llmgateway.go b/llmgateway.go index 53de021..8901039 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -55,6 +55,11 @@ type Config struct { // (validateModelAliases, registry.go). ModelAliases map[string]string `json:"modelAliases,omitempty"` Providers map[string]*ProviderConfig `json:"providers,omitempty"` + // RequireModelPrefix, when true, makes every model addressable only as + // "provider/model" or as a ModelAliases id: a bare discovered id is not + // resolved (404 unknown model) and is not listed by GET /v1/models. It + // removes the sorted-first bare-id collision winner. Defaults to false. + RequireModelPrefix bool `json:"requireModelPrefix,omitempty"` // ModelMeta declares per-model metadata overrides (feature v0.23): // context window size and per-token cost, keyed by an exact // "provider/model" id or a bare model id (applying wherever that bare diff --git a/registry.go b/registry.go index faa5f38..71eeb47 100644 --- a/registry.go +++ b/registry.go @@ -664,6 +664,9 @@ type modelRegistry struct { // cheap nil-map lookup (always false/zero) that adds no branching // cost to the v0.1 no-aliases case. aliases map[string]string + // requirePrefix is Config.RequireModelPrefix: resolve and listFor treat + // a bare id as unaddressable, leaving "provider/model" and aliases. + requirePrefix bool // modelMeta is the validated Config.ModelMeta config-override map // (feature v0.23), built once by newModelRegistry via // validateModelMeta and never mutated afterward. nil (not merely @@ -882,6 +885,7 @@ func newModelRegistry(adapters map[string]providerAdapter, cfg *Config, log func return nil, err } m.aliases = aliases + m.requirePrefix = cfg.RequireModelPrefix modelMeta, err := validateModelMeta(cfg.ModelMeta) if err != nil { @@ -1603,6 +1607,9 @@ func (m *modelRegistry) resolve(id string, grp *group) (providerAdapter, string, if providerName, rest, ok := m.splitConfiguredProvider(id); ok { return m.resolveAgainst(providerName, rest, id, "", grp, errModelUnknown) } + if m.requirePrefix { + return nil, "", "", errModelUnknown + } providerName, ok := m.bareWinner(id, grp) if !ok { return nil, "", "", errModelUnknown @@ -1981,10 +1988,10 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any { // that branch (harmless — matchesGlob is cheap), but still // REQUIRED for the single-grant fast path, which never consults // id at all. - if gwOK && grp.allowsProviderModel(groupWinner, id) && !shadowed && !ambiguousBareID { + if gwOK && grp.allowsProviderModel(groupWinner, id) && !shadowed && !ambiguousBareID && !m.requirePrefix { out = append(out, modelObject(id, groupWinner, m.resolveMetaFor(groupWinner, id))) } - if len(visible) < 2 && !shadowed && !ambiguousBareID { + if len(visible) < 2 && !shadowed && !ambiguousBareID && !m.requirePrefix { continue // only one provider serving id is visible to grp: no disambiguating prefix to add } for _, p := range visible { diff --git a/registry_requireprefix_test.go b/registry_requireprefix_test.go new file mode 100644 index 0000000..721415a --- /dev/null +++ b/registry_requireprefix_test.go @@ -0,0 +1,115 @@ +package traefikllmgateway + +import ( + "errors" + "sort" + "testing" +) + +func newRequirePrefixRegistry(t *testing.T, require bool) (*modelRegistry, map[string]providerAdapter) { + t.Helper() + adapters := map[string]providerAdapter{ + "alpha": newFakeAdapter("alpha"), + "beta": newFakeAdapter("beta"), + } + cfg := &Config{ + RequireModelPrefix: require, + ModelAliases: map[string]string{"fast": "alpha/solo", "shared-alias": "shared"}, + Providers: map[string]*ProviderConfig{ + "alpha": {Models: []string{"shared", "solo"}}, + "beta": {Models: []string{"shared", "other"}}, + }, + } + reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {}) + if err != nil { + t.Fatalf("newModelRegistry: %v", err) + } + return reg, adapters +} + +func TestModelRegistry_RequireModelPrefix_Resolve(t *testing.T) { + t.Parallel() + reg, adapters := newRequirePrefixRegistry(t, true) + grp := allowAllGroup() + + for _, id := range []string{"solo", "shared", "other"} { + if _, _, _, err := reg.resolve(id, grp); !errors.Is(err, errModelUnknown) { + t.Errorf("resolve(%q) err = %v, want errModelUnknown", id, err) + } + } + + cases := []struct { + id string + provider string + canon string + }{ + {"alpha/solo", "alpha", "alpha/solo"}, + {"beta/shared", "beta", "beta/shared"}, + {"fast", "alpha", "alpha/solo"}, + {"shared-alias", "alpha", "alpha/shared"}, + } + for _, tc := range cases { + adapter, _, canon, err := reg.resolve(tc.id, grp) + if err != nil { + t.Errorf("resolve(%q): %v", tc.id, err) + continue + } + if adapter != adapters[tc.provider] || canon != tc.canon { + t.Errorf("resolve(%q) = %v %q, want %s %q", tc.id, adapter, canon, tc.provider, tc.canon) + } + } +} + +func TestModelRegistry_RequireModelPrefix_ListFor_NoBareIDs(t *testing.T) { + t.Parallel() + reg, _ := newRequirePrefixRegistry(t, true) + + var got []string + for _, entry := range reg.listFor(allowAllGroup()) { + got = append(got, entry["id"].(string)) //nolint:forcetypeassert // modelObject always sets id to a string + } + sort.Strings(got) + want := []string{"alpha/shared", "alpha/solo", "beta/other", "beta/shared", "fast", "shared-alias"} + if len(got) != len(want) { + t.Fatalf("listFor ids = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("listFor ids = %v, want %v", got, want) + } + } +} + +func TestModelRegistry_RequireModelPrefix_ListFor_RespectsProviderAllowList(t *testing.T) { + t.Parallel() + reg, _ := newRequirePrefixRegistry(t, true) + grp := &group{name: "g", providers: []string{"beta"}} + + for _, entry := range reg.listFor(grp) { + id := entry["id"].(string) //nolint:forcetypeassert // modelObject always sets id to a string + if entry["owned_by"] != "beta" { + t.Errorf("entry %q owned_by %v, want only beta-owned entries", id, entry["owned_by"]) + } + } + if _, _, _, err := reg.resolve("alpha/solo", grp); !errors.Is(err, errModelDenied) { + t.Errorf("resolve(alpha/solo) err = %v, want errModelDenied", err) + } +} + +func TestModelRegistry_RequireModelPrefix_Off_KeepsBareIDs(t *testing.T) { + t.Parallel() + reg, _ := newRequirePrefixRegistry(t, false) + + if _, _, canon, err := reg.resolve("solo", allowAllGroup()); err != nil || canon != "alpha/solo" { + t.Errorf("resolve(solo) = %q, %v, want alpha/solo, nil", canon, err) + } + var sawBare bool + for _, entry := range reg.listFor(allowAllGroup()) { + if entry["id"] == "solo" { + sawBare = true + } + } + if !sawBare { + t.Error("listFor lost bare id \"solo\" with requireModelPrefix off") + } +}