feat: model registry with discovery and aggregated /v1/models

Adds modelRegistry: per-provider explicit+discovered model sets, bare-id
and provider-prefixed resolution with sorted-name collision precedence,
group authorization pairing (allowsModel AND allowsProvider), and a
group-filtered OpenAI-compatible /v1/models catalog.

Discovery is lazy and throttled per provider: newGateway runs a
synchronous 5s-bounded warm fill for discovery-enabled providers
(non-fatal on error), and Gateway.ServeHTTP triggers a throttled,
inFlight-guarded background refresh per provider per interval
(default 1h, per-provider DiscoveryInterval parsed at construction).
A failed refresh keeps the previous discovered set (stale-while-error).

Wires GET /v1/models into ServeHTTP (auth required, 401 envelope
otherwise) and gives Gateway its adapters/registry fields, both built
in newGateway.

newModelRegistry's signature gained an error return beyond the task
brief's literal listing: an invalid DiscoveryInterval must be a
constructor error per the controller ruling, which the brief's bare
*modelRegistry signature could not express.
This commit is contained in:
2026-08-20 10:08:48 +01:00
parent f71d9d0b50
commit fdb81640ec
4 changed files with 1171 additions and 7 deletions
+29 -7
View File
@@ -101,13 +101,13 @@ func CreateConfig() *Config {
// Gateway is the Traefik middleware handler.
type Gateway struct {
next http.Handler
cfg *Config
auth *authStore
limiter *limiter
name string
// wired in later tasks: registry *modelRegistry, adapters
// map[string]providerAdapter
next http.Handler
cfg *Config
auth *authStore
limiter *limiter
registry *modelRegistry
adapters map[string]providerAdapter
name string
}
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
@@ -143,6 +143,21 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
lim.logf = g.errorf
g.limiter = lim
adapters, err := buildAdapters(config)
if err != nil {
return nil, err
}
g.adapters = adapters
registry, err := newModelRegistry(adapters, config, g.errorf)
if err != nil {
return nil, err
}
warmCtx, warmCancel := context.WithTimeout(ctx, warmFillTimeout)
defer warmCancel()
registry.warmFill(warmCtx)
g.registry = registry
setPricingWarnFn(func(msg string) {
if msg == warnCapMessage {
g.logf("%s", msg)
@@ -226,6 +241,13 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sw := &statusTrackingWriter{ResponseWriter: w}
defer recoverPanic(sw, g)
g.auth.maybeReload()
g.registry.maybeRefresh(r.Context())
if r.Method == http.MethodGet && r.URL.Path == "/v1/models" {
g.handleModels(sw, r)
return
}
if g.cfg.PassthroughUnknown {
g.next.ServeHTTP(sw, r)
return