mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-19 05:47:48 +00:00
feat(registry): model metadata capture and resolution
Config gets two new surfaces (llmgateway.go): Config.ModelMeta (map[string]*ModelMetaConfig, keyed by exact "provider/model" or a bare model id) for operator overrides, and ProviderConfig.MetadataPath for openai-type providers whose discovery should also capture per-model context (LM Studio's native /api/v0/models shape). modelmeta.go adds the layering itself: resolveModelMeta applies config override > discovered context > builtin table > absent for context, and config override/free > builtin > absent for cost (discovery never reports cost). resolveAliasModelMeta implements alias inheritance: an alias inherits its target's fully resolved metadata unless the alias itself has its own modelMeta entry, applied per field. validateModelMeta rejects negative values and a free:true/non-zero-cost contradiction at construction. registry.go wires this into the request path: providerState gains a discoveredContext map (stale-while-error, mirroring discovered's own convention) with contextFor/setDiscoveredContext; a new optional modelMetadataFetcher interface, type-asserted (interpreted-to-interpreted, documented as yaegi-safe against the Trap-4 restriction) rather than added to providerAdapter, lets warmFill/refreshProvider capture metadata alongside listModels without touching anthropic/gemini adapters at all. modelObject/listFor now attach resolved context_window/pricing to every GET /v1/models entry, including aliases (resolveMetaFor/ resolveMetaForAlias/resolveMetaForAliasName). provider_openai.go implements modelMetadataFetcher: fetchModelMetadata reads ProviderConfig.MetadataPath, preferring loaded_context_length over max_context_length when the LM Studio response reports both. providers.go wires MetadataPath onto the adapter in buildAdapters. tools/yaegi-check/main.go adds one cheap modelMeta config-override assertion to the existing attempt-accounting harness, proving resolveModelMeta's config-override layer and the context_window/pricing extension fields both run correctly under the real Yaegi interpreter.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
package traefikllmgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMetaAdapter wraps fakeAdapter, additionally implementing
|
||||
// modelMetadataFetcher (feature v0.23) — a test double for an
|
||||
// openai-type adapter configured with metadataPath, exercising
|
||||
// warmFill/refreshProvider's captureModelMetadata wiring without a real
|
||||
// HTTP fetch. Every other fakeAdapter-consuming test is unaffected: only
|
||||
// tests that explicitly construct a fakeMetaAdapter get this extra
|
||||
// capability.
|
||||
type fakeMetaAdapter struct {
|
||||
*fakeAdapter
|
||||
fetchModelMetadataFn func(context.Context) (map[string]int, error)
|
||||
}
|
||||
|
||||
func newFakeMetaAdapter(name string) *fakeMetaAdapter {
|
||||
return &fakeMetaAdapter{fakeAdapter: newFakeAdapter(name)}
|
||||
}
|
||||
|
||||
func (f *fakeMetaAdapter) fetchModelMetadata(ctx context.Context) (map[string]int, error) {
|
||||
if f.fetchModelMetadataFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return f.fetchModelMetadataFn(ctx)
|
||||
}
|
||||
|
||||
// TestModelRegistry_WarmFill_CapturesModelMetadata proves warmFill's
|
||||
// captureModelMetadata wiring: a discovery-enabled provider whose
|
||||
// adapter implements modelMetadataFetcher has its returned per-model
|
||||
// context lengths recorded on providerState, reachable through
|
||||
// resolveMetaFor.
|
||||
func TestModelRegistry_WarmFill_CapturesModelMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
fa := newFakeMetaAdapter("lmstudio")
|
||||
fa.listModelsFn = func(context.Context) ([]string, error) {
|
||||
return []string{"qwen2.5-0.5b"}, nil
|
||||
}
|
||||
fa.fetchModelMetadataFn = func(context.Context) (map[string]int, error) {
|
||||
return map[string]int{"qwen2.5-0.5b": 32768}, nil
|
||||
}
|
||||
adapters := map[string]providerAdapter{"lmstudio": fa}
|
||||
cfg := &Config{Providers: map[string]*ProviderConfig{"lmstudio": {Discovery: true, MetadataPath: "/api/v0/models"}}}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
|
||||
reg.warmFill(context.Background())
|
||||
|
||||
ctxVal, ok := reg.states["lmstudio"].contextFor("qwen2.5-0.5b")
|
||||
if !ok || ctxVal != 32768 {
|
||||
t.Errorf("contextFor(qwen2.5-0.5b) = (%d, %v), want (32768, true)", ctxVal, ok)
|
||||
}
|
||||
|
||||
meta := reg.resolveMetaFor("lmstudio", "qwen2.5-0.5b")
|
||||
if !meta.ContextKnown || meta.ContextTokens != 32768 {
|
||||
t.Errorf("resolveMetaFor = %+v, want ContextKnown=true ContextTokens=32768", meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelRegistry_WarmFill_MetadataFetchErrorNonFatal proves a failed
|
||||
// metadata capture is non-fatal to discovery itself (feature v0.23's own
|
||||
// "absent/failed = no metadata captured" ruling): listModels still
|
||||
// succeeds and populates the provider's known model set, one log line
|
||||
// records the metadata failure, and contextFor simply reports unknown.
|
||||
func TestModelRegistry_WarmFill_MetadataFetchErrorNonFatal(t *testing.T) {
|
||||
t.Parallel()
|
||||
fa := newFakeMetaAdapter("lmstudio")
|
||||
fa.listModelsFn = func(context.Context) ([]string, error) {
|
||||
return []string{"m1"}, nil
|
||||
}
|
||||
fa.fetchModelMetadataFn = func(context.Context) (map[string]int, error) {
|
||||
return nil, errUpstream
|
||||
}
|
||||
adapters := map[string]providerAdapter{"lmstudio": fa}
|
||||
cfg := &Config{Providers: map[string]*ProviderConfig{"lmstudio": {Discovery: true, MetadataPath: "/api/v0/models"}}}
|
||||
log := &recordingLog{}
|
||||
reg, err := newModelRegistry(adapters, cfg, log.fn)
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
|
||||
reg.warmFill(context.Background())
|
||||
|
||||
if !reg.states["lmstudio"].hasModel("m1") {
|
||||
t.Error("discovery's own model set must still populate despite a metadata capture failure")
|
||||
}
|
||||
if _, ok := reg.states["lmstudio"].contextFor("m1"); ok {
|
||||
t.Error("contextFor must report unknown after a failed metadata capture")
|
||||
}
|
||||
if log.count() == 0 {
|
||||
t.Error("want at least one log line recording the metadata capture failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelRegistry_MaybeRefresh_CapturesModelMetadata proves the SAME
|
||||
// wiring runs on the background refresh path (refreshProvider), not just
|
||||
// the synchronous warmFill — captureModelMetadata is shared between both.
|
||||
func TestModelRegistry_MaybeRefresh_CapturesModelMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
fa := newFakeMetaAdapter("lmstudio")
|
||||
fa.listModelsFn = func(context.Context) ([]string, error) {
|
||||
return []string{"m1"}, nil
|
||||
}
|
||||
fa.fetchModelMetadataFn = func(context.Context) (map[string]int, error) {
|
||||
return map[string]int{"m1": 8192}, nil
|
||||
}
|
||||
adapters := map[string]providerAdapter{"lmstudio": fa}
|
||||
cfg := &Config{Providers: map[string]*ProviderConfig{"lmstudio": {Discovery: true, DiscoveryInterval: "1h", MetadataPath: "/api/v0/models"}}}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
|
||||
reg.maybeRefresh(context.Background())
|
||||
waitUntil(t, time.Second, func() bool {
|
||||
_, ok := reg.states["lmstudio"].contextFor("m1")
|
||||
return ok
|
||||
})
|
||||
ctxVal, _ := reg.states["lmstudio"].contextFor("m1")
|
||||
if ctxVal != 8192 {
|
||||
t.Errorf("contextFor(m1) = %d, want 8192", ctxVal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelRegistry_ListFor_ExposesContextAndPricingFields proves
|
||||
// listFor's modelObject output carries "context_window" and "pricing"
|
||||
// when resolveMetaFor knows them (via a modelMeta config override here),
|
||||
// and omits both entirely for a model nothing knows anything about.
|
||||
func TestModelRegistry_ListFor_ExposesContextAndPricingFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test", "totally-unknown"}}},
|
||||
ModelMeta: map[string]*ModelMetaConfig{
|
||||
"gpt-test": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1_250_000, OutputCostPerMTokMicroUSD: 10_000_000},
|
||||
},
|
||||
}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
|
||||
got := reg.listFor(allowAllGroup())
|
||||
|
||||
var known, unknown map[string]any
|
||||
for _, entry := range got {
|
||||
switch entry["id"] {
|
||||
case "gpt-test":
|
||||
known = entry
|
||||
case "totally-unknown":
|
||||
unknown = entry
|
||||
}
|
||||
}
|
||||
if known == nil || unknown == nil {
|
||||
t.Fatalf("listFor missing expected entries: %v", got)
|
||||
}
|
||||
|
||||
if got := known["context_window"]; got != 128000 {
|
||||
t.Errorf(`known["context_window"] = %v, want 128000`, got)
|
||||
}
|
||||
pricing, ok := known["pricing"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf(`known["pricing"] = %v, want a map`, known["pricing"])
|
||||
}
|
||||
if pricing["input_per_mtok_usd"] != 1.25 || pricing["output_per_mtok_usd"] != 10.0 {
|
||||
t.Errorf("pricing = %v, want {input_per_mtok_usd:1.25 output_per_mtok_usd:10}", pricing)
|
||||
}
|
||||
|
||||
if _, present := unknown["context_window"]; present {
|
||||
t.Errorf(`unknown["context_window"] present (%v), want omitted entirely`, unknown["context_window"])
|
||||
}
|
||||
if _, present := unknown["pricing"]; present {
|
||||
t.Errorf(`unknown["pricing"] present (%v), want omitted entirely`, unknown["pricing"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelRegistry_ListFor_Alias_InheritsTargetMetadata proves an
|
||||
// alias's listFor entry carries its target's resolved metadata
|
||||
// (modelObject via resolveMetaForAlias), and that an alias-level
|
||||
// modelMeta entry overrides it.
|
||||
func TestModelRegistry_ListFor_Alias_InheritsTargetMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
||||
|
||||
t.Run("inherits target metadata with no alias override", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}},
|
||||
ModelAliases: map[string]string{"aliased/coding": "openai/gpt-test"},
|
||||
ModelMeta: map[string]*ModelMetaConfig{
|
||||
"openai/gpt-test": {ContextTokens: 128000, Free: true},
|
||||
},
|
||||
}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
got := reg.listFor(allowAllGroup())
|
||||
var alias map[string]any
|
||||
for _, entry := range got {
|
||||
if entry["id"] == "aliased/coding" {
|
||||
alias = entry
|
||||
}
|
||||
}
|
||||
if alias == nil {
|
||||
t.Fatalf("listFor missing alias entry: %v", got)
|
||||
}
|
||||
if alias["context_window"] != 128000 {
|
||||
t.Errorf(`alias["context_window"] = %v, want 128000 (inherited)`, alias["context_window"])
|
||||
}
|
||||
pricing, ok := alias["pricing"].(map[string]any)
|
||||
if !ok || pricing["input_per_mtok_usd"] != 0.0 {
|
||||
t.Errorf(`alias["pricing"] = %v, want a free (zero) pricing object (inherited)`, alias["pricing"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alias-level override wins over inherited context", func(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}},
|
||||
ModelAliases: map[string]string{"aliased/coding": "openai/gpt-test"},
|
||||
ModelMeta: map[string]*ModelMetaConfig{
|
||||
"openai/gpt-test": {ContextTokens: 128000},
|
||||
"aliased/coding": {ContextTokens: 999},
|
||||
},
|
||||
}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
got := reg.listFor(allowAllGroup())
|
||||
var alias map[string]any
|
||||
for _, entry := range got {
|
||||
if entry["id"] == "aliased/coding" {
|
||||
alias = entry
|
||||
}
|
||||
}
|
||||
if alias == nil {
|
||||
t.Fatalf("listFor missing alias entry: %v", got)
|
||||
}
|
||||
if alias["context_window"] != 999 {
|
||||
t.Errorf(`alias["context_window"] = %v, want 999 (alias override)`, alias["context_window"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestModelRegistry_ResolveMetaForAliasName covers the admin dashboard's
|
||||
// unrestricted alias-metadata resolution directly: an unconfigured
|
||||
// alias, one whose target has not resolved yet, and a real, resolvable
|
||||
// one.
|
||||
func TestModelRegistry_ResolveMetaForAliasName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("unconfigured alias reports everything unknown", func(t *testing.T) {
|
||||
reg := newAliasTestRegistry(t, map[string]string{"aliased/coding": "openai/gpt-test"}, nil)
|
||||
got := reg.resolveMetaForAliasName("not-an-alias")
|
||||
if got.ContextKnown || got.CostKnown {
|
||||
t.Errorf("got %+v, want everything unknown", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unresolved target reports everything unknown", func(t *testing.T) {
|
||||
reg := newAliasTestRegistry(t, map[string]string{"aliased/future": "openai/not-yet-discovered"}, nil)
|
||||
got := reg.resolveMetaForAliasName("aliased/future")
|
||||
if got.ContextKnown || got.CostKnown {
|
||||
t.Errorf("got %+v, want everything unknown", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolvable alias ignores group authorization entirely", func(t *testing.T) {
|
||||
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Models: []string{"gpt-test"}}},
|
||||
ModelAliases: map[string]string{"aliased/coding": "openai/gpt-test"},
|
||||
ModelMeta: map[string]*ModelMetaConfig{"openai/gpt-test": {ContextTokens: 55555}},
|
||||
}
|
||||
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatalf("newModelRegistry: %v", err)
|
||||
}
|
||||
got := reg.resolveMetaForAliasName("aliased/coding")
|
||||
if !got.ContextKnown || got.ContextTokens != 55555 {
|
||||
t.Errorf("got %+v, want ContextKnown=true ContextTokens=55555", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user