ci: yaegi catalog-analyzer gate harness

Add tools/yaegi-check, a separate Go module (llmgw-yaegi-check,
never a dependency of the plugin module) that replicates the
Traefik Plugin Catalog analyzer (piceus)'s yaegiMiddlewareCheck:
copy the repo into a temp GOPATH, interp.New + i.Use(stdlib.Symbols),
import the plugin package, eval CreateConfig(), decode the plugin's
real .traefik.yml testData into it via a hand-rolled YAML-subset
parser plus a JSON round-trip, reflect-Call New from outside the
interpreter, then exercise the returned handler with two real HTTP
requests (authenticated GET /v1/models against the testData key, and
the same route unauthenticated). Pins github.com/traefik/yaegi to
v0.16.1, confirmed as Traefik v3.5.0's own vendored version by
downloading its go.mod.

Wire cd tools/yaegi-check && GOWORK=off go run . /Users/nvm/Documents/projects/private/traefik-llmgateway
yaegi-check: OK to run the harness from repo root.

The harness caught a real Yaegi v0.16.1 limitation: calling a struct
FIELD of variadic func type (e.g. modelRegistry.log
func(string, ...any)) with two or more variadic arguments crashes
Yaegi's CFG builder ('index out of range'). The identical call shape
through a method or interface method is unaffected — bisected with a
standalone yaegi repro to confirm the exact trigger before touching
plugin code. Fixed by pre-formatting with fmt.Sprintf at
modelRegistry's three affected call sites (registry.go) so the field
is always called with exactly one variadic argument; documented the
constraint on both modelRegistry.log and limiter.logf so it is not
reintroduced. Verified the gate itself is load-bearing by temporarily
renaming CreateConfig (harness failed non-zero) and restoring it
(harness passed again).

Also fixes two golangci-lint findings in the new harness module
(govet shadow, gosec G301 directory permissions).
This commit is contained in:
2026-08-20 12:16:51 +01:00
parent b879b21975
commit 0e73558f5c
7 changed files with 542 additions and 12 deletions
+15 -5
View File
@@ -122,8 +122,18 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
// guarded by its own mutex), so modelRegistry itself needs no lock beyond
// warnedMu for the collision-log dedup below.
type modelRegistry struct {
adapters map[string]providerAdapter
states map[string]*providerState
adapters map[string]providerAdapter
states map[string]*providerState
// log is a bound method value (g.errorf), injected rather than holding
// a *Gateway directly. Every call site below pre-formats with
// fmt.Sprintf and calls log("%s", msg) — never log(format, a, b, ...)
// with two or more variadic arguments. Yaegi v0.16.1's CFG builder
// panics ("index out of range") compiling a call to a struct FIELD of
// variadic func type with 2+ variadic arguments; the identical call
// shape through a method or interface method is unaffected — this is
// specific to a field holding a func value. Verified empirically
// against yaegi v0.16.1 (see tools/yaegi-check) and does not apply to
// g.logf/g.errorf, which are methods, not fields.
log func(string, ...any)
nowFn func() time.Time
warned map[string]bool
@@ -205,7 +215,7 @@ func (m *modelRegistry) warmFill(ctx context.Context) {
cancel()
st.finishRefresh(m.now(), ids, err)
if err != nil {
m.log("model registry: initial discovery for provider %q failed: %v", name, err)
m.log("%s", fmt.Sprintf("model registry: initial discovery for provider %q failed: %v", name, err))
}
}
}
@@ -264,7 +274,7 @@ func (m *modelRegistry) refreshProvider(name string, st *providerState, adapter
}
st.finishRefresh(m.now(), ids, err)
if err != nil {
m.log("model registry: discovery refresh for provider %q failed: %v", name, err)
m.log("%s", fmt.Sprintf("model registry: discovery refresh for provider %q failed: %v", name, err))
}
}()
@@ -371,7 +381,7 @@ func (m *modelRegistry) warnCollisionOnce(id, winner string, provs []string) {
return
}
m.warned[id] = true
m.log("model registry: model id %q is provided by multiple providers %v; %q wins the bare id", id, provs, winner)
m.log("%s", fmt.Sprintf("model registry: model id %q is provided by multiple providers %v; %q wins the bare id", id, provs, winner))
}
// listFor returns grp's visible model catalog as OpenAI-compatible model