Files
traefik-llmgateway/routes_unified_modelmeta_test.go
lukaszraczylo 0c61296c73 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.
2026-08-22 01:35:21 +01:00

93 lines
3.3 KiB
Go

package traefikllmgateway
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestHandleModels_ContextWindowAndPricing_FieldPresenceOmission is the
// HTTP-level counterpart to registry_modelmeta_test.go's listFor
// coverage: a real GET /v1/models request through the full Gateway,
// proving the OpenAI-compat-safe extension fields (feature v0.23:
// "context_window", "pricing") are present with the expected values for
// a model with known metadata, present-with-zeros for an explicitly free
// one, and entirely absent (not present as null or 0) for a model
// nothing knows anything about.
func TestHandleModels_ContextWindowAndPricing_FieldPresenceOmission(t *testing.T) {
t.Parallel()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{
"openai": {Type: "openai", APIKey: "sk-test", Models: []string{"gpt-test", "free-model", "unknown-model"}},
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "tester", Group: "default", APIKey: "sk-user"}}}
cfg.ModelMeta = map[string]*ModelMetaConfig{
"gpt-test": {ContextTokens: 128000, InputCostPerMTokMicroUSD: 1_250_000, OutputCostPerMTokMicroUSD: 10_000_000},
"free-model": {ContextTokens: 32768, Free: true},
}
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
req.Header.Set("Authorization", "Bearer sk-user")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
byID := make(map[string]map[string]any, len(body.Data))
for _, entry := range body.Data {
id, _ := entry["id"].(string)
byID[id] = entry
}
priced, ok := byID["gpt-test"]
if !ok {
t.Fatalf("response missing gpt-test: %v", body.Data)
}
if priced["context_window"] != 128000.0 {
t.Errorf(`gpt-test["context_window"] = %v, want 128000`, priced["context_window"])
}
pricing, ok := priced["pricing"].(map[string]any)
if !ok || pricing["input_per_mtok_usd"] != 1.25 || pricing["output_per_mtok_usd"] != 10.0 {
t.Errorf(`gpt-test["pricing"] = %v, want {input_per_mtok_usd:1.25 output_per_mtok_usd:10}`, priced["pricing"])
}
free, ok := byID["free-model"]
if !ok {
t.Fatalf("response missing free-model: %v", body.Data)
}
freePricing, ok := free["pricing"].(map[string]any)
if !ok {
t.Fatalf(`free-model["pricing"] missing, want present with zeros (explicit free, not absent)`)
}
if freePricing["input_per_mtok_usd"] != 0.0 || freePricing["output_per_mtok_usd"] != 0.0 {
t.Errorf(`free-model["pricing"] = %v, want zeros`, freePricing)
}
unknown, ok := byID["unknown-model"]
if !ok {
t.Fatalf("response missing unknown-model: %v", body.Data)
}
if _, present := unknown["context_window"]; present {
t.Errorf(`unknown-model["context_window"] present (%v), want entirely omitted`, unknown["context_window"])
}
if _, present := unknown["pricing"]; present {
t.Errorf(`unknown-model["pricing"] present (%v), want entirely omitted`, unknown["pricing"])
}
}