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:
2026-08-22 01:35:21 +01:00
parent e8398e6d49
commit 0c61296c73
10 changed files with 1331 additions and 15 deletions
+117
View File
@@ -528,6 +528,106 @@ func TestOpenAIAdapter_ListModels(t *testing.T) {
}
}
// TestOpenAIAdapter_FetchModelMetadata covers fetchModelMetadata (feature
// v0.23) against an httptest fixture shaped like LM Studio's real, live
// "/api/v0/models" response (task brief: llm/vlm models report
// max_context_length 262144, qwen2.5-0.5b 32768, embeddings 512-8192) —
// including the loaded_context_length-preferred-over-max_context_length
// rule, an entry with no loaded_context_length at all, and a
// metadataPath left unset (the default: a fast no-op, no HTTP call at
// all).
func TestOpenAIAdapter_FetchModelMetadata(t *testing.T) {
t.Run("metadataPath unset is a fast no-op", func(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
a := newOpenAIAdapter("p1", srv.URL, "sk-test")
got, err := a.fetchModelMetadata(context.Background())
if err != nil || got != nil {
t.Errorf("fetchModelMetadata = (%v, %v), want (nil, nil)", got, err)
}
if called {
t.Error("unset metadataPath must never issue an HTTP request")
}
})
t.Run("LM Studio shaped fixture: prefers loaded over max, falls back when absent", func(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
{"id":"qwen/qwen3-vl-30b","max_context_length":262144,"loaded_context_length":262144},
{"id":"qwen2.5-0.5b","max_context_length":32768,"loaded_context_length":4096},
{"id":"text-embedding-nomic-embed-text-v1.5","max_context_length":2048,"loaded_context_length":512},
{"id":"no-loaded-field","max_context_length":8192},
{"id":"zero-everything","max_context_length":0,"loaded_context_length":0}
]}`))
}))
defer srv.Close()
a := newOpenAIAdapter("lmstudio", srv.URL, "")
a.metadataPath = "/api/v0/models"
got, err := a.fetchModelMetadata(context.Background())
if err != nil {
t.Fatalf("fetchModelMetadata: %v", err)
}
if gotPath != "/api/v0/models" {
t.Errorf("request path = %q, want /api/v0/models", gotPath)
}
want := map[string]int{
"qwen/qwen3-vl-30b": 262144,
"qwen2.5-0.5b": 4096, // loaded_context_length preferred over max_context_length
"text-embedding-nomic-embed-text-v1.5": 512,
"no-loaded-field": 8192, // falls back to max_context_length
// "zero-everything" omitted entirely: both fields are 0.
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %#v, want %#v", got, want)
}
})
t.Run("non-2xx response is an error, not a captured empty map", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
a := newOpenAIAdapter("p1", srv.URL, "")
a.metadataPath = "/api/v0/models"
if _, err := a.fetchModelMetadata(context.Background()); err == nil {
t.Error("want an error for a non-2xx metadata response")
}
})
t.Run("malformed JSON body is a decode error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{not json`))
}))
defer srv.Close()
a := newOpenAIAdapter("p1", srv.URL, "")
a.metadataPath = "/api/v0/models"
if _, err := a.fetchModelMetadata(context.Background()); err == nil {
t.Error("want a decode error for a malformed metadata response body")
}
})
t.Run("unreachable upstream is an error", func(t *testing.T) {
a := newOpenAIAdapter("p1", "http://127.0.0.1:1", "") // reserved, never listening
a.metadataPath = "/api/v0/models"
if _, err := a.fetchModelMetadata(context.Background()); err == nil {
t.Error("want an error when the metadata endpoint is unreachable")
}
})
}
// TestOpenAIAdapter_Keyless proves a resolved-empty API key sends no
// Authorization header at all, not one with an empty token — the
// operator's own keyless gateway is a real deployment target.
@@ -591,6 +691,23 @@ func TestBuildAdapters(t *testing.T) {
}
})
t.Run("good: metadataPath is wired onto the openai adapter (feature v0.23)", func(t *testing.T) {
cfg := &Config{Providers: map[string]*ProviderConfig{
"lmstudio": {Type: "openai", MetadataPath: "/api/v0/models"},
}}
adapters, err := buildAdapters(cfg)
if err != nil {
t.Fatalf("buildAdapters: %v", err)
}
a, ok := adapters["lmstudio"].(*openaiAdapter)
if !ok {
t.Fatalf("adapters[%q] is not *openaiAdapter", "lmstudio")
}
if a.metadataPath != "/api/v0/models" {
t.Errorf("metadataPath = %q, want %q", a.metadataPath, "/api/v0/models")
}
})
t.Run("good: explicit base URL trailing slash trimmed", func(t *testing.T) {
cfg := &Config{Providers: map[string]*ProviderConfig{
"p1": {Type: "openai", BaseURL: "https://custom.example.com/", APIKey: "sk-test"},