mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
A user entry can now list several groups in "groups", next to the existing "group" field. A user can also hold a personal grant with its own "providers" and optional "models" lists. Users with one group and no personal grant behave exactly as before. A request is allowed only when one grant allows the provider, the model and, for passthrough, the path. Grants never merge, so one grant cannot supply the provider while another supplies the model or the path. The personal grant uses the path list of the first group. A personal "models" list without "providers" is a configuration error. Each member group applies its own limits. Caching stays on unless a member group turns it off, and the shortest group TTL wins. The /v1/models cache uses a stable, length-prefixed key, so reloads do not grow it. Tests cover the grant union, cross-grant denials, passthrough path and model coupling, reloads and the cache key. The yaegi-check harness asserts the same cases under the interpreter. Claude-Session: https://claude.ai/code/session_019uJHQKcNLMACkXssb3L7Q3
2190 lines
86 KiB
Go
2190 lines
86 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeAdapter is a providerAdapter test double whose listModels behavior is
|
|
// scripted per test via listModelsFn. Its other methods are never exercised
|
|
// by registry.go, so they return harmless zero values.
|
|
type fakeAdapter struct {
|
|
listModelsFn func(context.Context) ([]string, error)
|
|
adapterName string
|
|
}
|
|
|
|
func newFakeAdapter(name string) *fakeAdapter {
|
|
return &fakeAdapter{adapterName: name}
|
|
}
|
|
|
|
func (f *fakeAdapter) name() string { return f.adapterName }
|
|
func (f *fakeAdapter) typeName() string { return "fake" }
|
|
func (f *fakeAdapter) base() string { return "" }
|
|
func (f *fakeAdapter) injectAuth(*http.Request) {}
|
|
func (f *fakeAdapter) chatCompletion(context.Context, http.ResponseWriter, map[string]any) (usage, error) {
|
|
return usage{}, nil
|
|
}
|
|
func (f *fakeAdapter) embeddings(context.Context, http.ResponseWriter, map[string]any) (usage, error) {
|
|
return usage{}, nil
|
|
}
|
|
func (f *fakeAdapter) imagesGeneration(context.Context, http.ResponseWriter, map[string]any) (usage, error) {
|
|
return usage{}, nil
|
|
}
|
|
func (f *fakeAdapter) audioSpeech(context.Context, http.ResponseWriter, []byte, string) (usage, error) {
|
|
return usage{}, nil
|
|
}
|
|
func (f *fakeAdapter) audioTranscription(context.Context, http.ResponseWriter, []byte, string) (usage, error) {
|
|
return usage{}, nil
|
|
}
|
|
func (f *fakeAdapter) listModels(ctx context.Context) ([]string, error) {
|
|
if f.listModelsFn == nil {
|
|
return nil, nil
|
|
}
|
|
return f.listModelsFn(ctx)
|
|
}
|
|
func (f *fakeAdapter) httpClient() *http.Client { return nil }
|
|
func (f *fakeAdapter) requestTimeout() time.Duration { return defaultRequestTimeout }
|
|
|
|
// recordingLog is a thread-safe log func for tests, capturing formatted
|
|
// lines instead of writing to stderr.
|
|
type recordingLog struct {
|
|
lines []string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (r *recordingLog) fn(format string, args ...any) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.lines = append(r.lines, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (r *recordingLog) count() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.lines)
|
|
}
|
|
|
|
// waitUntil polls cond every millisecond until it returns true, failing the
|
|
// test if timeout elapses first. Used to synchronize against background
|
|
// refresh goroutines without a fixed sleep.
|
|
func waitUntil(t *testing.T, timeout time.Duration, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
if cond() {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("condition not met before timeout")
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// allowAllGroup has no provider/model restrictions.
|
|
func allowAllGroup() *group { return &group{name: "all"} }
|
|
|
|
// --- resolve: prefix vs bare-id disambiguation (ruling a) ---
|
|
|
|
func TestModelRegistry_Resolve_ProviderPrefixed_ResolvesDirect(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
adapter, upstreamModel, canonical, err := reg.resolve("openai/gpt-test", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["openai"] {
|
|
t.Errorf("adapter = %v, want openai adapter", adapter)
|
|
}
|
|
if upstreamModel != "gpt-test" {
|
|
t.Errorf("upstreamModel = %q, want %q", upstreamModel, "gpt-test")
|
|
}
|
|
if canonical != "openai/gpt-test" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "openai/gpt-test")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_BareIDContainingSlash_UnconfiguredPrefix_ResolvesAsBareID
|
|
// is the real-case regression from ruling (a): an upstream model id
|
|
// ("uni/deepseek-v4-flash-0731") that itself contains a slash must resolve
|
|
// as a bare id against whichever configured provider lists it, because
|
|
// "uni" itself is not a configured provider name.
|
|
func TestModelRegistry_Resolve_BareIDContainingSlash_UnconfiguredPrefix_ResolvesAsBareID(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"acme": newFakeAdapter("acme"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"acme": {Models: []string{"uni/deepseek-v4-flash-0731"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
const id = "uni/deepseek-v4-flash-0731"
|
|
adapter, upstreamModel, canonical, err := reg.resolve(id, allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["acme"] {
|
|
t.Errorf("adapter = %v, want acme adapter", adapter)
|
|
}
|
|
if upstreamModel != id {
|
|
t.Errorf("upstreamModel = %q, want unchanged %q", upstreamModel, id)
|
|
}
|
|
if canonical != "acme/"+id {
|
|
t.Errorf("canonical = %q, want %q", canonical, "acme/"+id)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_UnknownModel_ReturnsErrModelUnknown(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
if _, _, _, err := reg.resolve("does-not-exist", allowAllGroup()); err != errModelUnknown {
|
|
t.Errorf("err = %v, want errModelUnknown", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_EmptyProviderModelSet_ResolvesNothing(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {}}} // no explicit models, discovery off
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
if _, _, _, err := reg.resolve("openai/anything", allowAllGroup()); err != errModelUnknown {
|
|
t.Errorf("err = %v, want errModelUnknown", err)
|
|
}
|
|
if _, _, _, err := reg.resolve("anything", allowAllGroup()); err != errModelUnknown {
|
|
t.Errorf("err = %v, want errModelUnknown", err)
|
|
}
|
|
}
|
|
|
|
// --- resolve: authorization pairing (ruling c) ---
|
|
|
|
func TestModelRegistry_Resolve_KnownModel_ProviderDenied_ReturnsErrModelDenied(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"anthropic": newFakeAdapter("anthropic"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"anthropic": {Models: []string{"claude-x"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
// providers:[openai] restricts the provider; models:[claude-*] would
|
|
// match claude-x on its own — the provider gate must still deny it.
|
|
grp := &group{name: "openai-only", providers: []string{"openai"}, models: []string{"claude-*"}}
|
|
if _, _, _, err := reg.resolve("anthropic/claude-x", grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_KnownModel_ModelGlobDenied_ReturnsErrModelDenied(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "narrow", providers: []string{"openai"}, models: []string{"gpt-4*"}}
|
|
if _, _, _, err := reg.resolve("openai/gpt-test", grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_AllowedByBothProviderAndModel_Succeeds(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "ok", providers: []string{"openai"}, models: []string{"gpt-*"}}
|
|
if _, _, _, err := reg.resolve("openai/gpt-test", grp); err != nil {
|
|
t.Errorf("resolve: %v, want success", err)
|
|
}
|
|
}
|
|
|
|
// --- resolve: exact-glob model authorization, no spurious prefix-strip
|
|
// (fix(registry) ruling 3) ---
|
|
|
|
// TestModelRegistry_Resolve_BareIDWithSlash_ModelGlobMustNotMatchViaSpuriousStrip
|
|
// is the regression: allowsModel used to strip at any first slash, so a
|
|
// pattern like "deepseek-*" would spuriously match a bare model id that
|
|
// merely contains a slash — an upstream's own "uni/deepseek-v4-flash-0731"
|
|
// naming, where "uni" is not a configured provider and there is no
|
|
// legitimate prefix to strip at all. resolveAgainst must not invent that
|
|
// candidate for a request that was never provider-prefixed in the first
|
|
// place.
|
|
func TestModelRegistry_Resolve_BareIDWithSlash_ModelGlobMustNotMatchViaSpuriousStrip(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"acme": newFakeAdapter("acme")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"acme": {Models: []string{"uni/deepseek-v4-flash-0731"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
const id = "uni/deepseek-v4-flash-0731"
|
|
grp := &group{name: "narrow", models: []string{"deepseek-*"}}
|
|
if _, _, _, err := reg.resolve(id, grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied (deepseek-* must not spuriously match via a bare-suffix strip)", err)
|
|
}
|
|
|
|
// Positive control: a pattern that legitimately targets the full bare
|
|
// id, slash included, still matches via plain exact-glob.
|
|
grp.models = []string{"uni/deepseek*"}
|
|
if _, _, _, err := reg.resolve(id, grp); err != nil {
|
|
t.Errorf("resolve with a full-string-matching pattern: %v, want success", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_ProviderPrefixed_ModelGlobMatchesViaBareCandidate
|
|
// is the positive half of the same ruling: a provider-prefixed request's
|
|
// bare suffix is still checked against the model glob — resolveAgainst
|
|
// generates that candidate itself now that allowsModel no longer strips.
|
|
func TestModelRegistry_Resolve_ProviderPrefixed_ModelGlobMatchesViaBareCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "narrow", models: []string{"gpt-*"}}
|
|
if _, _, _, err := reg.resolve("openai/gpt-test", grp); err != nil {
|
|
t.Errorf("resolve: %v, want success (gpt-* must match via the bare-suffix candidate)", err)
|
|
}
|
|
}
|
|
|
|
// --- resolve: multi-group / personal-grant authorization ---
|
|
|
|
// TestModelRegistry_Resolve_MultiGroup_UnionAcrossMemberGroups proves a
|
|
// multi-group principal (effectiveGroup, auth.go) is authorized through
|
|
// EITHER member group's own grant — the union rule.
|
|
func TestModelRegistry_Resolve_MultiGroup_UnionAcrossMemberGroups(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"minimax": newFakeAdapter("minimax"),
|
|
"gx10": newFakeAdapter("gx10"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"minimax": {Models: []string{"m1"}},
|
|
"gx10": {Models: []string{"only"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
a := &group{name: "minimax-friends", providers: []string{"minimax"}}
|
|
b := &group{name: "gx10-only", providers: []string{"gx10"}, models: []string{"gx10/only"}}
|
|
grp := effectiveGroup([]*group{a, b}, nil)
|
|
|
|
if _, _, _, err := reg.resolve("minimax/m1", grp); err != nil {
|
|
t.Errorf("resolve minimax/m1: %v, want success via member group a's unrestricted-model grant", err)
|
|
}
|
|
if _, _, _, err := reg.resolve("gx10/only", grp); err != nil {
|
|
t.Errorf("resolve gx10/only: %v, want success via member group b's own grant", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_MultiGroup_CrossGrantLeakDenied is the
|
|
// feature's core safety rule: grant A {providers:[minimax], models:[]}
|
|
// (models unrestricted, but only within minimax) must never combine with
|
|
// grant B {providers:[gx10], models:[gx10/only]} to authorize
|
|
// "gx10/other" — a naive "any grant allows the provider AND any grant
|
|
// allows the model" check would wrongly allow it, since A's own empty
|
|
// Models list matches anything on its own.
|
|
func TestModelRegistry_Resolve_MultiGroup_CrossGrantLeakDenied(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"minimax": newFakeAdapter("minimax"),
|
|
"gx10": newFakeAdapter("gx10"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"minimax": {Models: []string{"m1"}},
|
|
"gx10": {Models: []string{"only", "other"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
a := &group{name: "minimax-unrestricted", providers: []string{"minimax"}}
|
|
b := &group{name: "gx10-only", providers: []string{"gx10"}, models: []string{"gx10/only"}}
|
|
grp := effectiveGroup([]*group{a, b}, nil)
|
|
|
|
if _, _, _, err := reg.resolve("gx10/only", grp); err != nil {
|
|
t.Errorf("resolve gx10/only: %v, want success", err)
|
|
}
|
|
if _, _, _, err := reg.resolve("gx10/other", grp); err != errModelDenied {
|
|
t.Errorf("resolve gx10/other: err = %v, want errModelDenied (no cross-grant leak from A's unrestricted models)", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_PersonalProvidersGrant_AddsProviderKeepsGroupIntact
|
|
// covers a personal Providers-only grant layered on top of a group: group
|
|
// friends{providers:[minimax,uni]} plus a personal grant of
|
|
// providers:[gx10] must allow gx10 in addition to minimax/uni, unchanged,
|
|
// and deny every other provider.
|
|
func TestModelRegistry_Resolve_PersonalProvidersGrant_AddsProviderKeepsGroupIntact(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"minimax": newFakeAdapter("minimax"),
|
|
"uni": newFakeAdapter("uni"),
|
|
"gx10": newFakeAdapter("gx10"),
|
|
"other": newFakeAdapter("other"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"minimax": {Models: []string{"m1"}},
|
|
"uni": {Models: []string{"u1"}},
|
|
"gx10": {Models: []string{"g1"}},
|
|
"other": {Models: []string{"o1"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
friends := &group{name: "friends", providers: []string{"minimax", "uni"}}
|
|
grp := effectiveGroup([]*group{friends}, &grant{providers: []string{"gx10"}})
|
|
|
|
for _, id := range []string{"minimax/m1", "uni/u1", "gx10/g1"} {
|
|
if _, _, _, err := reg.resolve(id, grp); err != nil {
|
|
t.Errorf("resolve %s: %v, want success", id, err)
|
|
}
|
|
}
|
|
if _, _, _, err := reg.resolve("other/o1", grp); err != errModelDenied {
|
|
t.Errorf("resolve other/o1: err = %v, want errModelDenied (personal grant only adds gx10)", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_PersonalModelsOnlyGrant_ExactModelAllowedOthersDenied
|
|
// covers a personal Models-only grant: models:["gx10/GLM-5.3-Flash-EXL3"]
|
|
// allows exactly that id and denies "gx10/other", independent of the
|
|
// user's own group's (unrelated) model restriction.
|
|
func TestModelRegistry_Resolve_PersonalModelsOnlyGrant_ExactModelAllowedOthersDenied(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"gx10": newFakeAdapter("gx10")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"gx10": {Models: []string{"GLM-5.3-Flash-EXL3", "other"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
base := &group{name: "base", providers: []string{"gx10"}, models: []string{"nothing-else"}}
|
|
grp := effectiveGroup([]*group{base}, &grant{models: []string{"gx10/GLM-5.3-Flash-EXL3"}})
|
|
|
|
if _, _, _, err := reg.resolve("gx10/GLM-5.3-Flash-EXL3", grp); err != nil {
|
|
t.Errorf("resolve gx10/GLM-5.3-Flash-EXL3: %v, want success via personal grant", err)
|
|
}
|
|
if _, _, _, err := reg.resolve("gx10/other", grp); err != errModelDenied {
|
|
t.Errorf("resolve gx10/other: err = %v, want errModelDenied", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_MultiGroup_ShowsUnionOfMemberGroupAndPersonalGrant
|
|
// proves GET /v1/models (listFor) shows exactly the union: a member
|
|
// group's own unrestricted-model provider, and the personal grant's own
|
|
// restricted model, with everything the personal grant does not list
|
|
// absent.
|
|
func TestModelRegistry_ListFor_MultiGroup_ShowsUnionOfMemberGroupAndPersonalGrant(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"minimax": newFakeAdapter("minimax"),
|
|
"gx10": newFakeAdapter("gx10"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"minimax": {Models: []string{"m1"}},
|
|
"gx10": {Models: []string{"only", "other"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
member := &group{name: "minimax-friends", providers: []string{"minimax"}}
|
|
grp := effectiveGroup([]*group{member}, &grant{providers: []string{"gx10"}, models: []string{"only"}})
|
|
|
|
ids := map[string]bool{}
|
|
for _, entry := range reg.listFor(grp) {
|
|
ids[entry["id"].(string)] = true //nolint:forcetypeassert // modelObject always sets id to a string
|
|
}
|
|
if !ids["m1"] {
|
|
t.Errorf("listFor ids = %v, want \"m1\" present (member group's unrestricted grant on minimax)", ids)
|
|
}
|
|
if !ids["only"] {
|
|
t.Errorf("listFor ids = %v, want \"only\" present (personal grant)", ids)
|
|
}
|
|
if ids["other"] {
|
|
t.Errorf("listFor ids = %v, want \"other\" absent (personal grant restricts gx10 to \"only\")", ids)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_MultiGrant_BareWinnerPrefersGrantThatActuallyAuthorizes
|
|
// is HIGH-2's regression (review round 2): eng{providers:[beta]} alone
|
|
// already fully authorizes bare "llama" via beta (unrestricted models).
|
|
// Adding a personal grant restricted to alpha/special must NEVER take
|
|
// that away — a union-based bareWinner picks alpha first (visible via
|
|
// the personal grant's own provider entry) and then denies it (alpha's
|
|
// grant doesn't cover "llama"), even though beta, right behind it in
|
|
// sorted order, is fully authorized by eng's own grant. bareWinner (and
|
|
// listFor's identical bare-entry selection) must skip alpha and land on
|
|
// beta instead.
|
|
func TestModelRegistry_Resolve_MultiGrant_BareWinnerPrefersGrantThatActuallyAuthorizes(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"alpha": newFakeAdapter("alpha"),
|
|
"beta": newFakeAdapter("beta"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"alpha": {Models: []string{"llama", "special"}},
|
|
"beta": {Models: []string{"llama"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
eng := &group{name: "eng", providers: []string{"beta"}}
|
|
grp := effectiveGroup([]*group{eng}, &grant{providers: []string{"alpha"}, models: []string{"alpha/special"}})
|
|
|
|
adapter, upstreamModel, canonical, err := reg.resolve("llama", grp)
|
|
if err != nil {
|
|
t.Fatalf("resolve(llama): %v, want success via eng's own unrestricted-model grant on beta", err)
|
|
}
|
|
if adapter != adapters["beta"] {
|
|
t.Errorf("adapter = %v, want beta's adapter", adapter)
|
|
}
|
|
if upstreamModel != "llama" || canonical != "beta/llama" {
|
|
t.Errorf("upstreamModel/canonical = %q/%q, want \"llama\"/\"beta/llama\"", upstreamModel, canonical)
|
|
}
|
|
|
|
ids := map[string]bool{}
|
|
for _, e := range reg.listFor(grp) {
|
|
ids[e["id"].(string)] = true
|
|
}
|
|
if !ids["llama"] {
|
|
t.Errorf("listFor ids = %v, want bare \"llama\" still listed (via eng's own grant on beta)", ids)
|
|
}
|
|
}
|
|
|
|
// --- resolve: collision precedence (ruling g) ---
|
|
|
|
func TestModelRegistry_Resolve_BareIDCollision_PicksSortedFirstProvider(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
adapter, _, canonical, err := reg.resolve("shared", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["alpha"] {
|
|
t.Errorf("adapter = %v, want alpha (sorted-first)", adapter)
|
|
}
|
|
if canonical != "alpha/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "alpha/shared")
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_ProviderPrefixed_AlwaysReachesLosingProvider(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
adapter, _, canonical, err := reg.resolve("beta/shared", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["beta"] {
|
|
t.Errorf("adapter = %v, want beta (explicit form bypasses collision winner)", adapter)
|
|
}
|
|
if canonical != "beta/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "beta/shared")
|
|
}
|
|
}
|
|
|
|
// --- resolve: GROUP-AWARE collision precedence (2026-08-27 fix) ---
|
|
|
|
// TestModelRegistry_Resolve_BareIDCollision_GroupAwareWinner is the exact
|
|
// production repro from the bug report: "alpha" and "zeta" both serve
|
|
// "shared". alpha sorts first, so bareWinner used to pick it globally
|
|
// regardless of the caller — a group authorized ONLY for zeta got
|
|
// errModelDenied for a model it was actually entitled to use, purely
|
|
// because alpha (a provider the group cannot even see) happened to sort
|
|
// earlier. The fix restricts bareWinner's candidate search to providers
|
|
// grp.allowsProvider admits, falling back to the global winner only when
|
|
// none of them qualify.
|
|
// Mutation that must make this test fail: revert bareWinner to its
|
|
// pre-fix, group-blind body (`for _, name := range m.providerNames { if
|
|
// m.states[name].hasModel(id) { return name, true } }`, ignoring grp
|
|
// entirely) — resolve then picks "alpha", grp.allowsProvider("alpha") is
|
|
// false, and this returns errModelDenied instead of serving zeta. Verified:
|
|
// applying that exact revert turns this failure red (errModelDenied, want
|
|
// nil) before restoring the fix.
|
|
func TestModelRegistry_Resolve_BareIDCollision_GroupAwareWinner(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"alpha": newFakeAdapter("alpha"),
|
|
"zeta": newFakeAdapter("zeta"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"alpha": {Models: []string{"shared"}},
|
|
"zeta": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "friends", providers: []string{"zeta"}}
|
|
adapter, upstreamModel, canonical, err := reg.resolve("shared", grp)
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v, want success (zeta serves \"shared\" and this group may use zeta)", err)
|
|
}
|
|
if adapter != adapters["zeta"] {
|
|
t.Errorf("adapter = %v, want zeta", adapter)
|
|
}
|
|
if upstreamModel != "shared" {
|
|
t.Errorf("upstreamModel = %q, want %q", upstreamModel, "shared")
|
|
}
|
|
if canonical != "zeta/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "zeta/shared")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_BareIDCollision_BothAllowed_MatchesGlobalWinner
|
|
// is the required non-regression: a group explicitly authorized for BOTH
|
|
// colliding providers must resolve to the exact same provider it did
|
|
// before this fix — the sorted-first one, "alpha" — with no behavior
|
|
// change at all for a caller who could already reach today's global
|
|
// winner.
|
|
// Mutation that must make this test fail: reverse bareWinner's provider
|
|
// iteration order (`for i := len(m.providerNames) - 1; i >= 0; i--`) —
|
|
// this group can reach both providers, so the now-sorted-last provider
|
|
// "zeta" would win instead of "alpha". Verified: applying that reversal
|
|
// turns this failure red (adapter = zeta, want alpha) before restoring
|
|
// the fix.
|
|
func TestModelRegistry_Resolve_BareIDCollision_BothAllowed_MatchesGlobalWinner(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"alpha": newFakeAdapter("alpha"),
|
|
"zeta": newFakeAdapter("zeta"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"alpha": {Models: []string{"shared"}},
|
|
"zeta": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "both", providers: []string{"alpha", "zeta"}}
|
|
adapter, _, canonical, err := reg.resolve("shared", grp)
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["alpha"] {
|
|
t.Errorf("adapter = %v, want alpha (sorted-first, unchanged from today)", adapter)
|
|
}
|
|
if canonical != "alpha/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "alpha/shared")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_BareIDCollision_NeitherAllowed_SameDenialAsToday
|
|
// covers the other required non-regression: a group authorized for
|
|
// NEITHER colliding provider must still get errModelDenied — the same
|
|
// sentinel a known-but-unauthorized model returns today — never the wrong
|
|
// errModelUnknown a "no owner found" result would produce.
|
|
// Mutation that must make this test fail: make bareWinner return ("",
|
|
// false) when grp allows none of id's owners, instead of falling back to
|
|
// the global sorted-first owner — resolve's `if !ok { return ...,
|
|
// errModelUnknown }` branch then fires, and this test's errModelDenied
|
|
// assertion fails against the wrong sentinel. Verified: that change turns
|
|
// this failure red (err = errModelUnknown, want errModelDenied) before
|
|
// restoring the fallback.
|
|
func TestModelRegistry_Resolve_BareIDCollision_NeitherAllowed_SameDenialAsToday(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"alpha": newFakeAdapter("alpha"),
|
|
"zeta": newFakeAdapter("zeta"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"alpha": {Models: []string{"shared"}},
|
|
"zeta": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "outsiders", providers: []string{"unrelated"}}
|
|
if _, _, _, err := reg.resolve("shared", grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied", err)
|
|
}
|
|
}
|
|
|
|
// --- newModelRegistry construction ---
|
|
|
|
func TestNewModelRegistry_InvalidDiscoveryInterval_ReturnsConstructorError(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {DiscoveryInterval: "not-a-duration"}}}
|
|
if _, err := newModelRegistry(adapters, cfg, func(string, ...any) {}); err == nil {
|
|
t.Fatal("want constructor error for an invalid discoveryInterval")
|
|
}
|
|
}
|
|
|
|
func TestNewModelRegistry_EmptyDiscoveryInterval_DefaultsOneHour(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
if got := reg.states["openai"].interval; got != defaultDiscoveryInterval {
|
|
t.Errorf("interval = %v, want %v", got, defaultDiscoveryInterval)
|
|
}
|
|
}
|
|
|
|
// --- warmFill: synchronous initial discovery (ruling e) ---
|
|
|
|
func TestModelRegistry_WarmFill_MergesExplicitAndDiscovered(t *testing.T) {
|
|
t.Parallel()
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) { return []string{"gpt-b", "gpt-a"}, nil }
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-a"}, Discovery: true}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
reg.warmFill(context.Background())
|
|
|
|
got := reg.states["openai"].knownIDs()
|
|
want := []string{"gpt-a", "gpt-b"}
|
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
|
t.Errorf("knownIDs = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_WarmFill_DiscoveryError_NonFatal_LogsAndLeavesExplicitModels(t *testing.T) {
|
|
t.Parallel()
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) { return nil, fmt.Errorf("upstream down") }
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-a"}, Discovery: true}}}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
reg.warmFill(context.Background())
|
|
|
|
if !reg.states["openai"].hasModel("gpt-a") {
|
|
t.Error("want explicit model gpt-a still known after a failed initial discovery fetch")
|
|
}
|
|
if rl.count() == 0 {
|
|
t.Error("want the discovery failure logged")
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_WarmFill_NonDiscoveryProvider_NeverCallsListModels(t *testing.T) {
|
|
t.Parallel()
|
|
var calls int32
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
return nil, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-a"}}}} // Discovery left false
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
reg.warmFill(context.Background())
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 0 {
|
|
t.Errorf("listModels called %d times, want 0 for a non-discovery provider", got)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_WarmFill_EachProviderGetsFullBudget_NotSharedAcrossProviders
|
|
// is the regression for llmgateway.go's old outer 5s wrap around the whole
|
|
// warmFill call (fix(registry) ruling 2): that outer wrap made
|
|
// warmFillTimeout a total budget shared across every discovery-enabled
|
|
// provider instead of a fresh one per provider, so a slow first provider
|
|
// ate into a later provider's deadline and could leave it resolving
|
|
// nothing for the whole first interval. warmFill derives each provider's
|
|
// timeout via context.WithTimeout(ctx, warmFillTimeout) fresh per
|
|
// iteration; this proves that yields close to the full budget for a later
|
|
// provider even after an earlier one took real wall-clock time, as long as
|
|
// ctx itself (as llmgateway.go now passes it) carries no deadline of its
|
|
// own.
|
|
func TestModelRegistry_WarmFill_EachProviderGetsFullBudget_NotSharedAcrossProviders(t *testing.T) {
|
|
t.Parallel()
|
|
slow := newFakeAdapter("a-slow")
|
|
slow.listModelsFn = func(context.Context) ([]string, error) {
|
|
time.Sleep(1200 * time.Millisecond) // wall-clock cost, not budget shared with b-fast
|
|
return []string{"slow-model"}, nil
|
|
}
|
|
var fastRemaining time.Duration
|
|
fast := newFakeAdapter("b-fast")
|
|
fast.listModelsFn = func(ctx context.Context) ([]string, error) {
|
|
if dl, ok := ctx.Deadline(); ok {
|
|
fastRemaining = time.Until(dl)
|
|
}
|
|
return []string{"fast-model"}, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"a-slow": slow, "b-fast": fast}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
// Sorted provider-name order drives warmFill's iteration: "a-slow"
|
|
// runs before "b-fast" for this test to be meaningful.
|
|
"a-slow": {Discovery: true},
|
|
"b-fast": {Discovery: true},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
// Mirrors llmgateway.go's fixed call site: an unbounded parent ctx, not
|
|
// one pre-wrapped to warmFillTimeout.
|
|
reg.warmFill(context.Background())
|
|
|
|
if fastRemaining < 4*time.Second {
|
|
t.Errorf("b-fast's received context deadline was %v from expiry, want >4s (its 5s warm-fill budget must not be reduced by a-slow's 1.2s elapsed time)", fastRemaining)
|
|
}
|
|
}
|
|
|
|
// --- maybeRefresh: throttling, in-flight guard, stale-while-error ---
|
|
|
|
func TestModelRegistry_MaybeRefresh_Throttled_OnePerInterval(t *testing.T) {
|
|
t.Parallel()
|
|
var calls int32
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
return []string{"m1"}, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
// clockMu guards clock: reg.nowFn runs on maybeRefresh's own
|
|
// background refresh goroutine (registry.go), concurrently with this
|
|
// test's own goroutine advancing the clock below a few lines down. A
|
|
// plain `now` variable closed over by both was a genuine data race
|
|
// (coordinator adversarial review, 2026-08-23) — the 10ms sleep below
|
|
// gave the background goroutine a chance to finish reading it first,
|
|
// but that is a race won by scheduling luck, not a happens-before
|
|
// guarantee, and -race caught it in roughly 14% of full-suite runs.
|
|
var clockMu sync.Mutex
|
|
clock := time.Now()
|
|
reg.nowFn = func() time.Time {
|
|
clockMu.Lock()
|
|
defer clockMu.Unlock()
|
|
return clock
|
|
}
|
|
advanceClock := func(d time.Duration) {
|
|
clockMu.Lock()
|
|
clock = clock.Add(d)
|
|
clockMu.Unlock()
|
|
}
|
|
|
|
reg.maybeRefresh(context.Background())
|
|
waitUntil(t, time.Second, func() bool { return atomic.LoadInt32(&calls) == 1 })
|
|
|
|
// Still within the same interval: a second entry must not refetch.
|
|
reg.maybeRefresh(context.Background())
|
|
time.Sleep(10 * time.Millisecond) // give a wrongly-spawned goroutine a chance to run
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Errorf("listModels called %d times within one interval, want 1", got)
|
|
}
|
|
|
|
advanceClock(2 * time.Hour) // past the interval: next entry should refetch
|
|
reg.maybeRefresh(context.Background())
|
|
waitUntil(t, time.Second, func() bool { return atomic.LoadInt32(&calls) == 2 })
|
|
}
|
|
|
|
// TestModelRegistry_MaybeRefresh_InFlightGuard_BlocksConcurrentFetch proves
|
|
// the guard is the inFlight flag, not just the interval check: the first
|
|
// fetch is held open (lastRefresh never advances while it's in flight), so
|
|
// the interval check alone would spuriously allow a second concurrent
|
|
// fetch. Only the inFlight flag prevents it.
|
|
func TestModelRegistry_MaybeRefresh_InFlightGuard_BlocksConcurrentFetch(t *testing.T) {
|
|
t.Parallel()
|
|
var calls int32
|
|
started := make(chan struct{}, 2)
|
|
release := make(chan struct{})
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
started <- struct{}{}
|
|
<-release
|
|
return []string{"m1"}, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
now := time.Now()
|
|
reg.nowFn = func() time.Time { return now }
|
|
|
|
reg.maybeRefresh(context.Background()) // spawns the in-flight fetch, synchronously marks inFlight
|
|
reg.maybeRefresh(context.Background()) // must be a no-op: inFlight already true
|
|
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("first refresh never started")
|
|
}
|
|
select {
|
|
case <-started:
|
|
t.Fatal("a second concurrent fetch started while the first was still in flight")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
waitUntil(t, time.Second, func() bool { return atomic.LoadInt32(&calls) == 1 && reg.states["openai"].hasModel("m1") })
|
|
}
|
|
|
|
// TestModelRegistry_RefreshProvider_AdapterPanics_RecoversAndReleasesInFlight
|
|
// is the regression for the missing recover() in refreshProvider (fix
|
|
// (registry) ruling 1): refreshProvider's "go" statement is the plugin's
|
|
// only background goroutine, running off any request's stack. An
|
|
// unrecovered panic there would crash the whole Traefik process, not just
|
|
// fail one refresh — there is no ServeHTTP caller above it to catch it.
|
|
// Reaching the end of this test at all is part of the proof: if recover()
|
|
// were missing, the panicking listModels call below would have already
|
|
// crashed the test binary.
|
|
func TestModelRegistry_RefreshProvider_AdapterPanics_RecoversAndReleasesInFlight(t *testing.T) {
|
|
t.Parallel()
|
|
var calls int32
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
n := atomic.AddInt32(&calls, 1)
|
|
if n == 1 {
|
|
panic("boom: adapter blew up")
|
|
}
|
|
return []string{"m1"}, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
now := time.Now()
|
|
reg.nowFn = func() time.Time { return now }
|
|
|
|
reg.maybeRefresh(context.Background())
|
|
// The log write happens strictly after finishRefresh inside
|
|
// refreshProvider's deferred recover handler (both under the same
|
|
// goroutine, in that order), so waiting for it also proves inFlight was
|
|
// released and lastRefresh advanced — not merely that the panic
|
|
// occurred. recordingLog's own mutex gives this a proper happens-before
|
|
// edge with the write, so this is race-safe without touching
|
|
// providerState's fields directly.
|
|
waitUntil(t, time.Second, func() bool { return rl.count() > 0 })
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("listModels called %d times, want exactly 1 before the panic", got)
|
|
}
|
|
|
|
// The guard was released, not left stuck true: a second attempt after
|
|
// the interval elapses retries rather than being throttled forever.
|
|
now = now.Add(2 * time.Hour)
|
|
reg.maybeRefresh(context.Background())
|
|
waitUntil(t, time.Second, func() bool { return reg.states["openai"].hasModel("m1") })
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Errorf("listModels called %d times after the retry window, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_MaybeRefresh_DiscoveryError_KeepsPreviousDiscoveredSet(t *testing.T) {
|
|
t.Parallel()
|
|
var fail atomic.Bool
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
if fail.Load() {
|
|
return nil, fmt.Errorf("upstream unreachable")
|
|
}
|
|
return []string{"m1"}, nil
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
reg.warmFill(context.Background()) // succeeds, populates discovered={m1}
|
|
if !reg.states["openai"].hasModel("m1") {
|
|
t.Fatal("warmFill did not populate m1")
|
|
}
|
|
|
|
now := time.Now().Add(2 * time.Hour)
|
|
reg.nowFn = func() time.Time { return now }
|
|
fail.Store(true)
|
|
before := rl.count()
|
|
|
|
reg.maybeRefresh(context.Background())
|
|
waitUntil(t, time.Second, func() bool { return rl.count() > before })
|
|
|
|
if !reg.states["openai"].hasModel("m1") {
|
|
t.Error("want the previous discovered set kept after a failed refresh (stale-while-error)")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Snapshot_ModelsStaleWhileError proves
|
|
// modelRegistry.snapshot()'s providerSnapshot.models — the admin
|
|
// dashboard's provider-model-accordion field (registry.go's
|
|
// providerState.snapshot) — reflects the same stale-while-error set
|
|
// hasModel/knownIDs already do: a refresh that fails after an earlier
|
|
// successful one leaves the previously discovered models in the
|
|
// snapshot, not an empty list. This is the existing semantics
|
|
// finishRefresh's own doc comment already documents; this test applies
|
|
// no special handling for the mid-refresh/failed case, it only asserts
|
|
// the models field is populated from whatever snapshot() already reads.
|
|
func TestModelRegistry_Snapshot_ModelsStaleWhileError(t *testing.T) {
|
|
t.Parallel()
|
|
var fail atomic.Bool
|
|
fa := newFakeAdapter("openai")
|
|
fa.listModelsFn = func(context.Context) ([]string, error) {
|
|
if fail.Load() {
|
|
return nil, fmt.Errorf("upstream unreachable")
|
|
}
|
|
return []string{"m2", "m1"}, nil // deliberately unsorted at the source
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": fa}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Discovery: true, DiscoveryInterval: "1h"}}}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
reg.warmFill(context.Background()) // succeeds, populates discovered={m1,m2}
|
|
|
|
snaps := reg.snapshot()
|
|
if len(snaps) != 1 {
|
|
t.Fatalf("snapshot = %+v, want 1 provider", snaps)
|
|
}
|
|
if got := snaps[0].models; len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
|
|
t.Fatalf("models after a successful discovery = %v, want sorted [m1 m2]", got)
|
|
}
|
|
|
|
now := time.Now().Add(2 * time.Hour)
|
|
reg.nowFn = func() time.Time { return now }
|
|
fail.Store(true)
|
|
before := rl.count()
|
|
|
|
reg.maybeRefresh(context.Background())
|
|
waitUntil(t, time.Second, func() bool { return rl.count() > before })
|
|
|
|
snaps = reg.snapshot()
|
|
if got := snaps[0].models; len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
|
|
t.Errorf("models after a failed refresh = %v, want the stale-while-error set [m1 m2] unchanged", got)
|
|
}
|
|
if snaps[0].lastErr == "" {
|
|
t.Error("lastErr must be non-empty after the failed refresh, even though models stayed populated")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Snapshot_DiscoveryEnabledReflectsConfig proves
|
|
// providerSnapshot.discoveryEnabled (Feature B, v0.22 — last-refresh label
|
|
// honesty) mirrors each provider's own configured Discovery flag exactly,
|
|
// read straight from providerState rather than through providerState.
|
|
// snapshot()'s mutex-guarded fields, since it never changes after
|
|
// construction.
|
|
func TestModelRegistry_Snapshot_DiscoveryEnabledReflectsConfig(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"on": newFakeAdapter("on"),
|
|
"off": newFakeAdapter("off"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"on": {Discovery: true, DiscoveryInterval: "1h"},
|
|
"off": {Models: []string{"pinned"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
byName := map[string]providerSnapshot{}
|
|
for _, s := range reg.snapshot() {
|
|
byName[s.name] = s
|
|
}
|
|
if !byName["on"].discoveryEnabled {
|
|
t.Error(`provider "on" discoveryEnabled = false, want true`)
|
|
}
|
|
if byName["off"].discoveryEnabled {
|
|
t.Error(`provider "off" discoveryEnabled = true, want false`)
|
|
}
|
|
}
|
|
|
|
// --- listFor: group filtering, sorting, collision presentation (ruling h) ---
|
|
|
|
func TestModelRegistry_ListFor_GroupFiltered(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"anthropic": newFakeAdapter("anthropic"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"anthropic": {Models: []string{"claude-x"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "openai-only", providers: []string{"openai"}}
|
|
got := reg.listFor(grp)
|
|
if len(got) != 1 {
|
|
t.Fatalf("listFor = %v, want exactly 1 entry", got)
|
|
}
|
|
if got[0]["id"] != "gpt-test" || got[0]["owned_by"] != "openai" || got[0]["object"] != "model" {
|
|
t.Errorf("entry = %v, want {id:gpt-test, owned_by:openai, object:model}", got[0])
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_ListFor_SortedByID(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"zeta", "alpha", "mu"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
if len(got) != 3 {
|
|
t.Fatalf("listFor = %v, want 3 entries", got)
|
|
}
|
|
ids := []string{got[0]["id"].(string), got[1]["id"].(string), got[2]["id"].(string)}
|
|
want := []string{"alpha", "mu", "zeta"}
|
|
for i := range want {
|
|
if ids[i] != want[i] {
|
|
t.Errorf("ids = %v, want %v", ids, want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_ListFor_Collision_ListsBareWinnerPlusAllPrefixedForms(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
byID := make(map[string]string, len(got))
|
|
for _, e := range got {
|
|
byID[e["id"].(string)] = e["owned_by"].(string)
|
|
}
|
|
want := map[string]string{"shared": "alpha", "alpha/shared": "alpha", "beta/shared": "beta"}
|
|
if len(byID) != len(want) {
|
|
t.Fatalf("listFor = %v, want exactly %v", byID, want)
|
|
}
|
|
for id, ownedBy := range want {
|
|
if byID[id] != ownedBy {
|
|
t.Errorf("entry %q owned_by = %q, want %q", id, byID[id], ownedBy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_Collision_LogsWarningOnceRegardlessOfCallCount
|
|
// covers ruling (g)'s "log warn once per colliding id": repeated listFor
|
|
// calls over the same collision must not re-emit the warning.
|
|
func TestModelRegistry_ListFor_Collision_LogsWarningOnceRegardlessOfCallCount(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
reg.listFor(allowAllGroup())
|
|
reg.listFor(allowAllGroup())
|
|
reg.listFor(allowAllGroup())
|
|
|
|
if got := rl.count(); got != 1 {
|
|
t.Errorf("collision log emitted %d times across 3 listFor calls, want exactly 1", got)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_Collision_SingleVisibleProviderGetsBareID is
|
|
// the group-aware listing fix (2026-08-27): "alpha" and "beta" both serve
|
|
// "shared", but a group that may only reach "beta" sees no reason to know
|
|
// "alpha" exists at all. Before the fix, listFor always computed the bare
|
|
// id's owner from the GLOBAL sorted-first provider ("alpha" here) and
|
|
// only fell back to a "provider/id"-prefixed entry for a group that could
|
|
// not use it — so this exact group saw "beta/shared", a prefix that
|
|
// disambiguates against a provider it can never see in the first place.
|
|
// Mutation that must make this test fail: revert listFor's winner
|
|
// selection to the unconditional `winner := provs[0]` global choice (this
|
|
// commit's registry.go diff) — the entry becomes "beta/shared" again.
|
|
func TestModelRegistry_ListFor_Collision_SingleVisibleProviderGetsBareID(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "beta-only", providers: []string{"beta"}}
|
|
got := reg.listFor(grp)
|
|
if len(got) != 1 {
|
|
t.Fatalf("listFor = %v, want exactly 1 entry", got)
|
|
}
|
|
if got[0]["id"] != "shared" || got[0]["owned_by"] != "beta" {
|
|
t.Errorf("entry = %v, want {id:shared, owned_by:beta} (bare, not prefixed)", got[0])
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_Collision_TwoVisibleProvidersKeepBothPrefixedForms
|
|
// covers the OTHER half of the same fix: when a group can see two or more
|
|
// of an id's owning providers, the collision is real FROM THAT GROUP'S OWN
|
|
// VIEW, so both "provider/id" forms stay listed (letting the group reach
|
|
// either explicitly) alongside a bare-id entry attributed to whichever of
|
|
// the group's own visible providers sorts first — "alpha" is configured
|
|
// but invisible to this group entirely and must not appear anywhere in the
|
|
// output, including as the bare id's owner.
|
|
// Mutation that must make this test fail: change listFor's prefixed-form
|
|
// gate back to the global `len(provs) < 2` (this commit's registry.go
|
|
// diff) — with alpha invisible but still counted, the gate does not
|
|
// change here (provs already has 2 entries either way), so instead mutate
|
|
// the winner used for the bare entry back to the unconditional
|
|
// `provs[0]` — the bare entry's owned_by becomes "alpha", a provider this
|
|
// group is never authorized to reach.
|
|
func TestModelRegistry_ListFor_Collision_TwoVisibleProvidersKeepBothPrefixedForms(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"gamma": newFakeAdapter("gamma"),
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"gamma": {Models: []string{"shared"}},
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
// alpha sorts first globally but is invisible to this group.
|
|
grp := &group{name: "beta-and-gamma", providers: []string{"beta", "gamma"}}
|
|
got := reg.listFor(grp)
|
|
|
|
byID := make(map[string]map[string]any, len(got))
|
|
for _, entry := range got {
|
|
byID[entry["id"].(string)] = entry
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("listFor = %v, want exactly 3 entries (bare + 2 prefixed)", got)
|
|
}
|
|
if entry, ok := byID["shared"]; !ok || entry["owned_by"] != "beta" {
|
|
t.Errorf("bare entry = %v, want owned_by beta (sorted-first of the group's OWN visible providers)", entry)
|
|
}
|
|
if entry, ok := byID["beta/shared"]; !ok || entry["owned_by"] != "beta" {
|
|
t.Errorf("beta/shared entry = %v, want owned_by beta", entry)
|
|
}
|
|
if entry, ok := byID["gamma/shared"]; !ok || entry["owned_by"] != "gamma" {
|
|
t.Errorf("gamma/shared entry = %v, want owned_by gamma", entry)
|
|
}
|
|
if _, ok := byID["alpha/shared"]; ok {
|
|
t.Errorf("listFor = %v, must never list alpha/shared: alpha is invisible to this group", got)
|
|
}
|
|
}
|
|
|
|
// --- modelsJSON: cached encoded /v1/models body (perf finding 3) ---
|
|
|
|
// TestModelRegistry_ModelsJSON_CachesUntilFinishRefresh proves the perf
|
|
// fix: a group's encoded body is the SAME []byte (not merely
|
|
// byte-equal — literally not re-marshaled) across repeated calls, until
|
|
// finishRefresh actually records a refresh attempt for some provider
|
|
// (modelsGen bumps), at which point the next call reflects the change.
|
|
func TestModelRegistry_ModelsJSON_CachesUntilFinishRefresh(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "all"}
|
|
first := reg.modelsJSON(grp)
|
|
if len(first) == 0 {
|
|
t.Fatal("modelsJSON returned an empty body")
|
|
}
|
|
second := reg.modelsJSON(grp)
|
|
if len(first) == 0 || len(second) == 0 || &first[0] != &second[0] {
|
|
t.Error("two calls between refreshes returned different backing arrays, want the identical cached []byte")
|
|
}
|
|
if !strings.Contains(string(first), "gpt-test") {
|
|
t.Errorf("body = %s, want it to contain %q", first, "gpt-test")
|
|
}
|
|
|
|
// A discovery refresh — even for a provider whose own discovery is
|
|
// disabled here, driven directly via the registry's own finishRefresh
|
|
// wrapper rather than a real background goroutine — must invalidate
|
|
// the cache.
|
|
reg.finishRefresh(reg.states["openai"], reg.now(), []string{"gpt-test", "gpt-new"}, nil)
|
|
|
|
third := reg.modelsJSON(grp)
|
|
if string(third) == string(second) {
|
|
t.Error("modelsJSON body unchanged after finishRefresh added a new model, want it to reflect the new catalog")
|
|
}
|
|
if !strings.Contains(string(third), "gpt-new") {
|
|
t.Errorf("body after refresh = %s, want it to contain the newly discovered %q", third, "gpt-new")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ModelsJSON_PerGroupIsolation proves the cache never
|
|
// leaks one group's catalog to another: two groups authorized for
|
|
// disjoint providers must each get their own encoded body, keyed by
|
|
// *group pointer identity (modelsJSON's own doc comment).
|
|
func TestModelRegistry_ModelsJSON_PerGroupIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"anthropic": newFakeAdapter("anthropic"),
|
|
}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"anthropic": {Models: []string{"claude-x"}},
|
|
}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grpOpenAI := &group{name: "openai-only", providers: []string{"openai"}}
|
|
grpAnthropic := &group{name: "anthropic-only", providers: []string{"anthropic"}}
|
|
|
|
bodyOpenAI := reg.modelsJSON(grpOpenAI)
|
|
bodyAnthropic := reg.modelsJSON(grpAnthropic)
|
|
|
|
if strings.Contains(string(bodyOpenAI), "claude-x") {
|
|
t.Errorf("openai-only group's body leaked anthropic's model: %s", bodyOpenAI)
|
|
}
|
|
if strings.Contains(string(bodyAnthropic), "gpt-test") {
|
|
t.Errorf("anthropic-only group's body leaked openai's model: %s", bodyAnthropic)
|
|
}
|
|
if !strings.Contains(string(bodyOpenAI), "gpt-test") {
|
|
t.Errorf("openai-only group's body = %s, want it to contain gpt-test", bodyOpenAI)
|
|
}
|
|
if !strings.Contains(string(bodyAnthropic), "claude-x") {
|
|
t.Errorf("anthropic-only group's body = %s, want it to contain claude-x", bodyAnthropic)
|
|
}
|
|
}
|
|
|
|
// TestGroup_ModelsCacheKey_NoAmbiguityBetweenMemberNamesAndPersonalGrantMarkers
|
|
// is the round-3 NIT regression: the cache key's own "p:"/"m:" markers
|
|
// used to be bare string literals inside the joined \x00-separated
|
|
// signature, so a member group literally NAMED "p:" or "m:" (group names
|
|
// carry no character-set validation — effectiveGroup's own doc comment,
|
|
// auth.go) could produce a signature IDENTICAL to an entirely different
|
|
// (membership, personal grant) combination: members=[A,"p:","x","m:"]
|
|
// (no personal grant) collided byte-for-byte with members=[A] plus a
|
|
// personal grant of providers:["x"] — both joined to
|
|
// "s:A\x00p:\x00x\x00m:". Both must now get distinct keys.
|
|
func TestGroup_ModelsCacheKey_NoAmbiguityBetweenMemberNamesAndPersonalGrantMarkers(t *testing.T) {
|
|
t.Parallel()
|
|
a := &group{name: "A"}
|
|
pColon := &group{name: "p:"}
|
|
x := &group{name: "x"}
|
|
mColon := &group{name: "m:"}
|
|
|
|
ambiguousMembers := effectiveGroup([]*group{a, pColon, x, mColon}, nil)
|
|
ambiguousPersonal := effectiveGroup([]*group{a}, &grant{providers: []string{"x"}})
|
|
|
|
keyMembers := ambiguousMembers.modelsCacheKey()
|
|
keyPersonal := ambiguousPersonal.modelsCacheKey()
|
|
if keyMembers == keyPersonal {
|
|
t.Errorf("modelsCacheKey collision: members=[A,p:,x,m:] (no personal) and members=[A]+personal{providers:[x]} both produced %q", keyMembers)
|
|
}
|
|
}
|
|
|
|
// TestGroup_ModelsCacheKey_LengthPrefixPreventsSplitPointAmbiguity is the
|
|
// round-4 regression: the test above already distinguishes its two
|
|
// signatures by member COUNT alone (4 vs 1), so it stays green even if
|
|
// writeLengthPrefixed's own length prefix were removed entirely — the
|
|
// count prefix ahead of the member list would still differ. This test
|
|
// isolates the length prefix's OWN job with two adversarial pairs:
|
|
//
|
|
// - members=["ab","c"] vs members=["a","bc"]: both concatenate to the
|
|
// IDENTICAL "abc" once a split point moves, so with NO separator at
|
|
// all (raw concatenation), both would produce "s2:abcN".
|
|
// - members=["a:b","c"] vs members=["a","b:c"] (round-6 regression):
|
|
// both concatenate to "a:b:c" — this pair specifically catches a
|
|
// WEAKER mutation than "no separator at all": writeLengthPrefixed
|
|
// keeping ONLY a bare ":" separator, with the length DIGITS dropped,
|
|
// would still produce ":a:b" + ":c" = ":a:b:c" for the first and
|
|
// ":a" + ":b:c" = ":a:b:c" for the second — identical
|
|
// ("s2::a:b:cN" either way) — because a bare ":" is not
|
|
// self-delimiting the way "<len>:" is; it does not tell a reader
|
|
// where the CURRENT component ends if the component's own value can
|
|
// itself contain ":".
|
|
//
|
|
// Only writeLengthPrefixed's own length-then-colon-then-value encoding
|
|
// (a netstring) keeps every pair apart: "2:ab1:c" is never confusable
|
|
// with "1:a2:bc", and "3:a:b1:c" is never confusable with "1:a3:b:c",
|
|
// regardless of what either component contains.
|
|
func TestGroup_ModelsCacheKey_LengthPrefixPreventsSplitPointAmbiguity(t *testing.T) {
|
|
t.Parallel()
|
|
splitAbC := effectiveGroup([]*group{{name: "ab"}, {name: "c"}}, nil)
|
|
splitABc := effectiveGroup([]*group{{name: "a"}, {name: "bc"}}, nil)
|
|
|
|
keyAbC := splitAbC.modelsCacheKey()
|
|
keyABc := splitABc.modelsCacheKey()
|
|
if keyAbC == keyABc {
|
|
t.Errorf("modelsCacheKey collision: members=[ab,c] and members=[a,bc] (same count, concatenation \"abc\" either way) both produced %q", keyAbC)
|
|
}
|
|
|
|
splitAColonBC := effectiveGroup([]*group{{name: "a:b"}, {name: "c"}}, nil)
|
|
splitABColonC := effectiveGroup([]*group{{name: "a"}, {name: "b:c"}}, nil)
|
|
|
|
keyAColonBC := splitAColonBC.modelsCacheKey()
|
|
keyABColonC := splitABColonC.modelsCacheKey()
|
|
if keyAColonBC == keyABColonC {
|
|
t.Errorf("modelsCacheKey collision: members=[a:b,c] and members=[a,b:c] (same count, concatenation \"a:b:c\" either way) both produced %q", keyAColonBC)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ModelsJSON_MultiGroupReload_CacheStaysBounded is
|
|
// MEDIUM-3's regression (review round 2): effectiveGroup (auth.go)
|
|
// allocates a FRESH *group for every multi-group/personal-grant
|
|
// principal, so a *group-pointer-keyed modelsCache grows without bound
|
|
// across repeated, semantically IDENTICAL users-file reloads (each
|
|
// reload rebuilds every user's principal from scratch, even when nothing
|
|
// changed). Five identical reloads of the same two-member-group user
|
|
// must leave the cache no larger than the number of DISTINCT principals
|
|
// actually queried (here: one multi-group user + one single-group user =
|
|
// 2 stable cache keys), not one entry per reload.
|
|
func TestModelRegistry_ModelsJSON_MultiGroupReload_CacheStaysBounded(t *testing.T) {
|
|
a, err := newAuthStore(&Config{Groups: map[string]*GroupConfig{"eng": {}, "ops": {}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg, err := newModelRegistry(map[string]providerAdapter{"alpha": newFakeAdapter("alpha")},
|
|
&Config{Providers: map[string]*ProviderConfig{"alpha": {Models: []string{"m1"}}}}, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 5; i++ {
|
|
if err := a.replaceFileUsers([]*UserConfig{
|
|
{Name: "carol", Group: "eng", Groups: []string{"ops"}, APIKey: "sk-carol"},
|
|
{Name: "dave", Group: "eng", APIKey: "sk-dave"},
|
|
}); err != nil {
|
|
t.Fatalf("replaceFileUsers (reload %d): %v", i, err)
|
|
}
|
|
for _, key := range []string{"sk-carol", "sk-dave"} {
|
|
_, grp, ok := identifyWithKey(a, key)
|
|
if !ok {
|
|
t.Fatalf("identify failed for %q on reload %d", key, i)
|
|
}
|
|
if body := reg.modelsJSON(grp); len(body) == 0 {
|
|
t.Fatalf("modelsJSON returned an empty body on reload %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
if got := len(reg.modelsCache); got > 2 {
|
|
t.Errorf("modelsCache entries after 5 identical reloads = %d, want at most 2 (one per DISTINCT principal — carol's multi-group principal must share one cache entry across reloads, not grow one per reload)", got)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ModelsJSON_MatchesUncachedListFor proves modelsJSON's
|
|
// output is byte-identical to the OLD, pre-cache call site
|
|
// (json.NewEncoder(w).Encode(...), routes_unified.go before perf finding
|
|
// 3) for the same envelope over listFor's own result — the cache changes
|
|
// WHEN the body is computed, never WHAT it computes, including
|
|
// json.Encoder.Encode's own trailing '\n' that a bare json.Marshal does
|
|
// not add (review fix, Should-Fix 5 — modelsJSON appends it explicitly).
|
|
func TestModelRegistry_ModelsJSON_MatchesUncachedListFor(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {Models: []string{"zeta", "alpha"}}}}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := allowAllGroup()
|
|
got := reg.modelsJSON(grp)
|
|
|
|
var want bytes.Buffer
|
|
if err := json.NewEncoder(&want).Encode(map[string]any{"object": "list", "data": reg.listFor(grp)}); err != nil {
|
|
t.Fatalf("json.NewEncoder.Encode: %v", err)
|
|
}
|
|
if string(got) != want.String() {
|
|
t.Errorf("modelsJSON = %q, want %q (byte-identical to the old json.Encoder.Encode call site)", got, want.String())
|
|
}
|
|
}
|
|
|
|
// --- end-to-end: GET /v1/models through Gateway.ServeHTTP ---
|
|
|
|
func TestGateway_ServeHTTP_GetV1Models_ReturnsGroupFilteredList(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{
|
|
"openai": {Type: "openai", APIKey: "k", Models: []string{"gpt-test"}},
|
|
"anthropic": {Type: "anthropic", APIKey: "k", Models: []string{"claude-x"}},
|
|
}
|
|
cfg.Groups = map[string]*GroupConfig{"openai-only": {Providers: []string{"openai"}}}
|
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "openai-only", APIKey: "sk-alice"}}}
|
|
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
req.Header.Set("Authorization", "Bearer sk-alice")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
var body struct {
|
|
Object string `json:"object"`
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Object != "list" {
|
|
t.Errorf("object = %q, want %q", body.Object, "list")
|
|
}
|
|
if len(body.Data) != 1 || body.Data[0]["id"] != "gpt-test" || body.Data[0]["owned_by"] != "openai" {
|
|
t.Errorf("data = %v, want exactly the openai gpt-test entry", body.Data)
|
|
}
|
|
}
|
|
|
|
func TestGateway_ServeHTTP_GetV1Models_Unauthenticated_Returns401(t *testing.T) {
|
|
t.Parallel()
|
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k", Models: []string{"gpt-test"}}}
|
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
|
|
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d, body: %s", rec.Code, http.StatusUnauthorized, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Error.Type == "" {
|
|
t.Error("want a non-empty error.type in the 401 envelope")
|
|
}
|
|
}
|
|
|
|
// TestNewGateway_WarmFill_PopulatesDiscoveredModels_BeforeAnyRequest proves
|
|
// newGateway's synchronous first-fill wiring: a discovery-enabled
|
|
// provider's models are known immediately after New returns, with no
|
|
// request ever sent.
|
|
func TestNewGateway_WarmFill_PopulatesDiscoveredModels_BeforeAnyRequest(t *testing.T) {
|
|
t.Parallel()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"data":[{"id":"disco-model"}]}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{
|
|
"openai": {Type: "openai", APIKey: "k", BaseURL: srv.URL, Discovery: true},
|
|
}
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw, ok := h.(*Gateway)
|
|
if !ok {
|
|
t.Fatal("handler is not *Gateway")
|
|
}
|
|
|
|
if !gw.registry.states["openai"].hasModel("disco-model") {
|
|
t.Fatal("want disco-model known immediately after New returns (synchronous warm fill)")
|
|
}
|
|
}
|
|
|
|
// --- model aliases (spec §5, v0.2) ---
|
|
|
|
// newAliasTestRegistry builds a registry with one "openai" provider
|
|
// (explicit model "gpt-test") and the given aliases, failing the test on
|
|
// any construction error. log defaults to a no-op when nil.
|
|
func newAliasTestRegistry(t *testing.T, aliases map[string]string, log func(string, ...any)) *modelRegistry {
|
|
t.Helper()
|
|
if log == nil {
|
|
log = func(string, ...any) {}
|
|
}
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}},
|
|
ModelAliases: aliases,
|
|
}
|
|
reg, err := newModelRegistry(adapters, cfg, log)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
return reg
|
|
}
|
|
|
|
// TestNewModelRegistry_ModelAliases_ValidationTable covers every row of
|
|
// spec §5's validation table, plus a positive control.
|
|
func TestNewModelRegistry_ModelAliases_ValidationTable(t *testing.T) {
|
|
t.Parallel()
|
|
baseProviders := map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}}
|
|
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
|
|
|
tests := []struct {
|
|
aliases map[string]string
|
|
name string
|
|
wantErrText string // substring expected in the error; "" means construction must succeed
|
|
}{
|
|
{
|
|
name: "empty alias id",
|
|
aliases: map[string]string{"": "gpt-test"},
|
|
wantErrText: "invalid",
|
|
},
|
|
{
|
|
name: "control character in alias id",
|
|
aliases: map[string]string{"bad\x7falias": "gpt-test"},
|
|
wantErrText: "invalid",
|
|
},
|
|
{
|
|
name: "alias prefix shadows a configured provider",
|
|
aliases: map[string]string{"openai/special": "gpt-test"},
|
|
wantErrText: "shadowed",
|
|
},
|
|
{
|
|
name: "alias collides with an explicit model id",
|
|
aliases: map[string]string{"gpt-test": "openai/gpt-test"},
|
|
wantErrText: "collides",
|
|
},
|
|
{
|
|
name: "alias chain: target is itself an alias",
|
|
aliases: map[string]string{"aliased/a": "aliased/b", "aliased/b": "openai/gpt-test"},
|
|
wantErrText: "itself an alias",
|
|
},
|
|
{
|
|
name: "empty target",
|
|
aliases: map[string]string{"aliased/x": ""},
|
|
wantErrText: "empty target",
|
|
},
|
|
{
|
|
name: "valid: bare target, no prefix collision",
|
|
aliases: map[string]string{"aliased/coding": "gpt-test"},
|
|
},
|
|
{
|
|
name: "valid: provider-prefixed target, unconfigured prefix on the alias itself",
|
|
aliases: map[string]string{"team/coding": "openai/gpt-test"},
|
|
},
|
|
{
|
|
name: "valid: target not yet known (lazy resolution, allowed at construction)",
|
|
aliases: map[string]string{"aliased/future": "openai/not-yet-discovered"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := &Config{Providers: baseProviders, ModelAliases: tt.aliases}
|
|
_, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if tt.wantErrText == "" {
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v, want success", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatal("newModelRegistry: want error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErrText) {
|
|
t.Errorf("err = %v, want substring %q", err, tt.wantErrText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_SlashLikeID_NotMistakenForProviderPrefix
|
|
// is the resolve-precedence regression from spec §5: an alias id
|
|
// containing "/" whose prefix names NO configured provider ("team" is not
|
|
// configured here — a prefix that IS configured is rejected at
|
|
// construction, so this is the only shape available to prove the alias
|
|
// map is checked, and wins, ahead of splitConfiguredProvider/bareWinner)
|
|
// must still resolve through the alias table, not fall through to
|
|
// errModelUnknown.
|
|
func TestModelRegistry_Resolve_Alias_SlashLikeID_NotMistakenForProviderPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"team/coding": "openai/gpt-test"}, nil)
|
|
|
|
adapter, upstreamModel, canonical, err := reg.resolve("team/coding", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != reg.adapters["openai"] {
|
|
t.Errorf("adapter = %v, want openai adapter", adapter)
|
|
}
|
|
if upstreamModel != "gpt-test" {
|
|
t.Errorf("upstreamModel = %q, want %q", upstreamModel, "gpt-test")
|
|
}
|
|
if canonical != "openai/gpt-test" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "openai/gpt-test")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_BareTarget_ResolvesViaCollisionWinner
|
|
// proves resolveAliasTarget's bareWinner branch (a target with no
|
|
// provider prefix) applies the identical sorted-first collision rule a
|
|
// direct bare-id request already gets (ruling g).
|
|
func TestModelRegistry_Resolve_Alias_BareTarget_ResolvesViaCollisionWinner(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"beta": newFakeAdapter("beta"),
|
|
"alpha": newFakeAdapter("alpha"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{
|
|
"beta": {Models: []string{"shared"}},
|
|
"alpha": {Models: []string{"shared"}},
|
|
},
|
|
ModelAliases: map[string]string{"aliased/x": "shared"},
|
|
}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
adapter, _, canonical, err := reg.resolve("aliased/x", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["alpha"] {
|
|
t.Errorf("adapter = %v, want alpha (sorted-first collision winner)", adapter)
|
|
}
|
|
if canonical != "alpha/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "alpha/shared")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_BareTarget_GroupAwareWinner is the alias
|
|
// path's counterpart to TestModelRegistry_Resolve_BareIDCollision_
|
|
// GroupAwareWinner (2026-08-27 fix): resolveAliasTarget's own bareWinner
|
|
// call must apply the identical group-aware collision rule the direct
|
|
// bare-id path does, not the pre-fix, group-blind one — the brief
|
|
// explicitly calls out this call site since it is easy to fix bareWinner
|
|
// itself and forget one of its two callers.
|
|
// Mutation that must make this test fail: hardcode resolveAliasTarget's
|
|
// `m.bareWinner(target, grp)` call to pass `&group{}` (allow-all) instead
|
|
// of the real grp — resolveAliasTarget would then always prefer "alpha"
|
|
// (the global winner) regardless of what this group may use, and this
|
|
// test's zeta assertion fails. Verified: that change turns this failure
|
|
// red (adapter = alpha, want zeta; also errModelDenied surfaces once
|
|
// resolveAgainst's own provider check runs against alpha) before
|
|
// restoring the real grp parameter.
|
|
func TestModelRegistry_Resolve_Alias_BareTarget_GroupAwareWinner(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"alpha": newFakeAdapter("alpha"),
|
|
"zeta": newFakeAdapter("zeta"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{
|
|
"alpha": {Models: []string{"shared"}},
|
|
"zeta": {Models: []string{"shared"}},
|
|
},
|
|
ModelAliases: map[string]string{"aliased/x": "shared"},
|
|
}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
|
|
grp := &group{name: "friends", providers: []string{"zeta"}}
|
|
adapter, _, canonical, err := reg.resolve("aliased/x", grp)
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v, want success via the alias's group-visible target provider", err)
|
|
}
|
|
if adapter != adapters["zeta"] {
|
|
t.Errorf("adapter = %v, want zeta", adapter)
|
|
}
|
|
if canonical != "zeta/shared" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "zeta/shared")
|
|
}
|
|
}
|
|
|
|
// --- resolve: alias authorization (spec §5's "alias name OR target,
|
|
// either grants") ---
|
|
|
|
func TestModelRegistry_Resolve_Alias_AuthorizedViaAliasNameOnly(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
// Matches the alias name; matches neither "openai/gpt-test" nor its
|
|
// bare suffix "gpt-test".
|
|
grp := &group{name: "narrow", models: []string{"aliased/coding"}}
|
|
if _, _, _, err := reg.resolve("aliased/coding", grp); err != nil {
|
|
t.Errorf("resolve: %v, want success (authorized via the alias name alone)", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_Alias_AuthorizedViaTargetOnly(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
// Matches the target's bare suffix; does not match the alias name.
|
|
grp := &group{name: "narrow", models: []string{"gpt-*"}}
|
|
if _, _, _, err := reg.resolve("aliased/coding", grp); err != nil {
|
|
t.Errorf("resolve: %v, want success (authorized via the resolved target alone)", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_Alias_DeniedWhenNeitherAliasNorTargetMatch(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
grp := &group{name: "narrow", models: []string{"claude-*"}}
|
|
if _, _, _, err := reg.resolve("aliased/coding", grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied", err)
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_Resolve_Alias_ProviderDenied_OverridesModelAuthz(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
// The model glob would allow it via the alias name, but the group's
|
|
// providers list excludes "openai" entirely — allowsProvider still
|
|
// gates the resolved target's provider (spec §5's "allowsProvider
|
|
// still applies to the target's provider").
|
|
grp := &group{name: "no-openai", providers: []string{"anthropic"}, models: []string{"aliased/coding"}}
|
|
if _, _, _, err := reg.resolve("aliased/coding", grp); err != errModelDenied {
|
|
t.Errorf("err = %v, want errModelDenied (provider gate must still apply)", err)
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_UnresolvedTarget_ReturnsAliasTargetError
|
|
// is spec §5's lazy-resolution 404: a target naming no currently-known
|
|
// model returns *aliasTargetError, whose message names both the alias and
|
|
// the missing target.
|
|
func TestModelRegistry_Resolve_Alias_UnresolvedTarget_ReturnsAliasTargetError(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/future": "openai/not-yet-discovered"}, nil)
|
|
|
|
_, _, _, err := reg.resolve("aliased/future", allowAllGroup())
|
|
var aerr *aliasTargetError
|
|
if !errors.As(err, &aerr) {
|
|
t.Fatalf("err = %v (%T), want *aliasTargetError", err, err)
|
|
}
|
|
if !strings.Contains(aerr.Error(), "aliased/future") {
|
|
t.Errorf("error message = %q, want it to name the alias %q", aerr.Error(), "aliased/future")
|
|
}
|
|
if !strings.Contains(aerr.Error(), "openai/not-yet-discovered") {
|
|
t.Errorf("error message = %q, want it to name the target %q", aerr.Error(), "openai/not-yet-discovered")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_TargetBecomesKnown_ResolvesAfterDiscovery
|
|
// proves the lazy side of "resolve lazily": a target that was unresolvable
|
|
// at construction succeeds once its provider's discovered set catches up
|
|
// (no re-validation needed — discovery mutates providerState directly).
|
|
func TestModelRegistry_Resolve_Alias_TargetBecomesKnown_ResolvesAfterDiscovery(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/future": "openai/not-yet-discovered"}, nil)
|
|
|
|
if _, _, _, err := reg.resolve("aliased/future", allowAllGroup()); err == nil {
|
|
t.Fatal("resolve before discovery: want an error, got nil")
|
|
}
|
|
|
|
reg.states["openai"].finishRefresh(reg.now(), []string{"not-yet-discovered"}, nil)
|
|
|
|
adapter, upstreamModel, canonical, err := reg.resolve("aliased/future", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve after discovery: %v, want success", err)
|
|
}
|
|
if adapter != reg.adapters["openai"] || upstreamModel != "not-yet-discovered" || canonical != "openai/not-yet-discovered" {
|
|
t.Errorf("resolve after discovery = (%v, %q, %q), want (openai adapter, %q, %q)", adapter, upstreamModel, canonical, "not-yet-discovered", "openai/not-yet-discovered")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_Resolve_Alias_PrecedesDiscoveredCollision_WarnsOnce
|
|
// covers the documented precedence edge (registry.go's resolve doc
|
|
// comment): an alias id that also happens to name a model discovered
|
|
// AFTER construction (impossible for an explicit model — that is a
|
|
// construction error) still resolves to the ALIAS's own target, and the
|
|
// gateway logs exactly one warning about the shadowed id, regardless of
|
|
// how many times resolve is called for it.
|
|
func TestModelRegistry_Resolve_Alias_PrecedesDiscoveredCollision_WarnsOnce(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"acme": newFakeAdapter("acme"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"acme": {Discovery: true},
|
|
},
|
|
ModelAliases: map[string]string{"shared-id": "openai/gpt-test"},
|
|
}
|
|
rl := &recordingLog{}
|
|
reg, err := newModelRegistry(adapters, cfg, rl.fn)
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
// acme's discovery finds a model literally named "shared-id" — the
|
|
// same string as the configured alias, discovered only after
|
|
// construction, so validateModelAliases never saw it.
|
|
reg.states["acme"].finishRefresh(reg.now(), []string{"shared-id"}, nil)
|
|
|
|
adapter, _, canonical, err := reg.resolve("shared-id", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["openai"] {
|
|
t.Errorf("adapter = %v, want openai (the alias, not acme's discovered model, must win)", adapter)
|
|
}
|
|
if canonical != "openai/gpt-test" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "openai/gpt-test")
|
|
}
|
|
|
|
if _, _, _, err := reg.resolve("shared-id", allowAllGroup()); err != nil {
|
|
t.Fatalf("second resolve: %v", err)
|
|
}
|
|
if got := rl.count(); got != 1 {
|
|
t.Errorf("collision-shadow log emitted %d times across 2 resolve calls, want exactly 1", got)
|
|
}
|
|
}
|
|
|
|
// --- listFor: alias visibility (spec §5) ---
|
|
|
|
func TestModelRegistry_ListFor_Alias_ListedWhenAuthorized_OwnedByTargetProvider(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
var found bool
|
|
for _, entry := range got {
|
|
if entry["id"] == "aliased/coding" {
|
|
found = true
|
|
if entry["owned_by"] != "openai" {
|
|
t.Errorf("aliased/coding owned_by = %v, want %q", entry["owned_by"], "openai")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("listFor = %v, want an entry for the alias %q", got, "aliased/coding")
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_ListFor_Alias_UnresolvedTarget_Omitted(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/future": "openai/not-yet-discovered"}, nil)
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
for _, entry := range got {
|
|
if entry["id"] == "aliased/future" {
|
|
t.Errorf("listFor lists %q whose target does not resolve yet, want it omitted", "aliased/future")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestModelRegistry_ListFor_Alias_NotAuthorized_Omitted(t *testing.T) {
|
|
t.Parallel()
|
|
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
|
|
|
grp := &group{name: "narrow", models: []string{"claude-*"}}
|
|
got := reg.listFor(grp)
|
|
for _, entry := range got {
|
|
if entry["id"] == "aliased/coding" {
|
|
t.Errorf("listFor lists %q for a group authorized for neither the alias nor its target", "aliased/coding")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_Alias_DedupedAgainstDiscoveredCollision is the
|
|
// review fix for listFor's alias-listing loop: when a provider's
|
|
// discovery fetch finds a model whose bare id is identical to a
|
|
// configured alias id (impossible for an EXPLICIT model —
|
|
// validateModelAliases already rejects that at construction, but
|
|
// discovery is dynamic and runs after that check), the listing must show
|
|
// exactly ONE entry for that id — not one from the real discovered model
|
|
// and a second, duplicate one from the alias loop. resolve's own
|
|
// precedence (registry.go's resolve doc comment) must still have the
|
|
// alias win at request time regardless of which entry the listing shows.
|
|
//
|
|
// A second scenario (review fix, second pass) shares this fixture: when
|
|
// grp cannot reach the COLLIDING provider at all (provider-denied), the
|
|
// real model's own entry is never emitted in the first place — the
|
|
// dedupe must key off entries actually LISTED, not the raw,
|
|
// authorization-blind owners map, or the id would vanish from the
|
|
// listing entirely (neither the real entry nor the alias appears) even
|
|
// though resolve still serves it through the alias.
|
|
func TestModelRegistry_ListFor_Alias_DedupedAgainstDiscoveredCollision(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"openai": newFakeAdapter("openai"),
|
|
"acme": newFakeAdapter("acme"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{
|
|
"openai": {Models: []string{"gpt-test"}},
|
|
"acme": {Discovery: true},
|
|
},
|
|
ModelAliases: map[string]string{"shared-id": "openai/gpt-test"},
|
|
}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
// acme's discovery finds a model literally named "shared-id" — the
|
|
// same string as the configured alias, discovered only after
|
|
// construction, so validateModelAliases never saw it.
|
|
reg.states["acme"].finishRefresh(reg.now(), []string{"shared-id"}, nil)
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
count := 0
|
|
for _, entry := range got {
|
|
if entry["id"] == "shared-id" {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("listFor has %d entries for id %q, want exactly 1 (deduped)", count, "shared-id")
|
|
}
|
|
|
|
// resolve must still prefer the alias, per its documented precedence,
|
|
// regardless of which single entry the listing showed above.
|
|
adapter, _, canonical, err := reg.resolve("shared-id", allowAllGroup())
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if adapter != adapters["openai"] {
|
|
t.Errorf("adapter = %v, want openai (the alias, not acme's discovered model, must still win resolution)", adapter)
|
|
}
|
|
if canonical != "openai/gpt-test" {
|
|
t.Errorf("canonical = %q, want %q", canonical, "openai/gpt-test")
|
|
}
|
|
|
|
// Provider-denied variant: grp can reach only "openai" — "acme", the
|
|
// colliding discovered model's own provider, is denied, so its raw
|
|
// entry is never emitted at all. The alias must still be listed
|
|
// exactly once, owned by its own target's provider ("openai") — the
|
|
// earlier owners-keyed dedupe would have wrongly skipped it too here,
|
|
// vanishing "shared-id" from the listing entirely.
|
|
narrowGrp := &group{name: "openai-only", providers: []string{"openai"}}
|
|
gotNarrow := reg.listFor(narrowGrp)
|
|
var narrowCount int
|
|
var ownedBy string
|
|
for _, entry := range gotNarrow {
|
|
if entry["id"] == "shared-id" {
|
|
narrowCount++
|
|
ownedBy, _ = entry["owned_by"].(string)
|
|
}
|
|
}
|
|
if narrowCount != 1 {
|
|
t.Fatalf("listFor (provider-denied collision) has %d entries for id %q, want exactly 1 (the alias)", narrowCount, "shared-id")
|
|
}
|
|
if ownedBy != "openai" {
|
|
t.Errorf("owned_by = %q, want %q (the alias's own target provider)", ownedBy, "openai")
|
|
}
|
|
}
|
|
|
|
// TestModelRegistry_ListFor_Alias_ShadowedCollisionKeepsAliasEntry proves the
|
|
// listing follows resolve's OWN precedence when a discovered model's bare id
|
|
// collides with a configured alias id: the single bare entry must be the
|
|
// ALIAS's (its target's owner and its resolved metadata), never the
|
|
// discovered model's, because every request for that id lands on the alias
|
|
// target instead (resolve's doc comment). The shadowed provider's own copy
|
|
// stays listed in "provider/id" form so it remains both visible and
|
|
// addressable.
|
|
//
|
|
// Live-fleet regression this fixes (2026-09-10): the alias
|
|
// "deepseek-v4-flash-vision-exp" -> "gx10/current" was listed as the cloud
|
|
// provider's identically-named model, owned_by that cloud provider and
|
|
// carrying NO context_window at all, while every request for the id ran
|
|
// against the local 1M-context model — so clients silently fell back to their
|
|
// own 128k default.
|
|
//
|
|
// Mutation that must make this test fail: restore listFor's old
|
|
// listed[alias]-keyed skip, which emitted the discovered model's entry and
|
|
// dropped the alias's.
|
|
func TestModelRegistry_ListFor_Alias_ShadowedCollisionKeepsAliasEntry(t *testing.T) {
|
|
t.Parallel()
|
|
adapters := map[string]providerAdapter{
|
|
"acme": newFakeAdapter("acme"),
|
|
"local": newFakeAdapter("local"),
|
|
}
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{
|
|
"acme": {Discovery: true},
|
|
"local": {Models: []string{"current"}},
|
|
},
|
|
ModelAliases: map[string]string{"shared-id": "local/current"},
|
|
ModelMeta: map[string]*ModelMetaConfig{
|
|
"local/current": {ContextTokens: 1048576, Free: true},
|
|
},
|
|
}
|
|
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
|
if err != nil {
|
|
t.Fatalf("newModelRegistry: %v", err)
|
|
}
|
|
// acme's discovery finds a model literally named "shared-id" — the same
|
|
// string as the configured alias, discovered only after construction, so
|
|
// validateModelAliases never saw it.
|
|
reg.states["acme"].finishRefresh(reg.now(), []string{"shared-id"}, nil)
|
|
|
|
got := reg.listFor(allowAllGroup())
|
|
byID := make(map[string]map[string]any, len(got))
|
|
bareCount := 0
|
|
for _, entry := range got {
|
|
id, _ := entry["id"].(string)
|
|
byID[id] = entry
|
|
if id == "shared-id" {
|
|
bareCount++
|
|
}
|
|
}
|
|
if bareCount != 1 {
|
|
t.Fatalf("listFor has %d entries for id %q, want exactly 1: %v", bareCount, "shared-id", got)
|
|
}
|
|
|
|
bare := byID["shared-id"]
|
|
if bare["owned_by"] != "local" {
|
|
t.Errorf("owned_by = %v, want %q (the alias's own target provider, matching resolve)", bare["owned_by"], "local")
|
|
}
|
|
if bare["context_window"] != 1048576 {
|
|
t.Errorf("context_window = %v, want 1048576 (the alias target's metadata)", bare["context_window"])
|
|
}
|
|
if _, ok := byID["acme/shared-id"]; !ok {
|
|
t.Errorf("listFor = %v, want the shadowed discovered model listed as %q", got, "acme/shared-id")
|
|
}
|
|
}
|