mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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.
93 lines
3.3 KiB
Go
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"])
|
|
}
|
|
}
|