mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-16 05:37:28 +00:00
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.
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user