diff --git a/.gitignore b/.gitignore index d5e1593..05e46bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .superpowers/ *.wasm +tools/yaegi-check/llmgw-yaegi-check diff --git a/Makefile b/Makefile index 6f19c7c..fd66adc 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ lint: go vet ./... yaegi-check: - @echo "yaegi-check: placeholder until Task 14" + cd tools/yaegi-check && GOWORK=off go run . $(CURDIR) integration: @echo "integration: placeholder until Task 15" diff --git a/limits.go b/limits.go index f80c9db..3d29d01 100644 --- a/limits.go +++ b/limits.go @@ -249,12 +249,19 @@ type limitViolation struct { // limiter enforces per-minute/day/month request, token, and cost limits // using fixed windows keyed by windowKey. type limiter struct { - store counterStore // configured backend; nil means always use fallback (see newLimiter) - fallback *memoryStore // in-process counter store, always available - nowFn func() time.Time // injected for tests; defaults to time.Now - logf func(format string, args ...any) // injectable store-error log; defaults to a no-op - lastLogAt time.Time // guarded by logMu; last time a store error was logged - lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched) + store counterStore // configured backend; nil means always use fallback (see newLimiter) + fallback *memoryStore // in-process counter store, always available + nowFn func() time.Time // injected for tests; defaults to time.Now + // logf is a bound method value (g.errorf), injectable for tests; defaults + // to a no-op. Its own call site (logStoreError) passes exactly one + // variadic argument — never extend that to two or more without first + // reading modelRegistry.log's doc comment in registry.go: a struct + // field of variadic func type crashes Yaegi v0.16.1's CFG builder past + // one variadic argument, even though the identical call through a + // method or interface method does not. + logf func(format string, args ...any) + lastLogAt time.Time // guarded by logMu; last time a store error was logged + lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched) logMu sync.Mutex failOpen bool // store-error policy: true falls back to fallback, false refuses the request } diff --git a/registry.go b/registry.go index 264d803..bfb4d6b 100644 --- a/registry.go +++ b/registry.go @@ -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 diff --git a/tools/yaegi-check/go.mod b/tools/yaegi-check/go.mod new file mode 100644 index 0000000..9791ced --- /dev/null +++ b/tools/yaegi-check/go.mod @@ -0,0 +1,5 @@ +module llmgw-yaegi-check + +go 1.22 + +require github.com/traefik/yaegi v0.16.1 diff --git a/tools/yaegi-check/go.sum b/tools/yaegi-check/go.sum new file mode 100644 index 0000000..435aa13 --- /dev/null +++ b/tools/yaegi-check/go.sum @@ -0,0 +1,2 @@ +github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= +github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY= diff --git a/tools/yaegi-check/main.go b/tools/yaegi-check/main.go new file mode 100644 index 0000000..9b56be7 --- /dev/null +++ b/tools/yaegi-check/main.go @@ -0,0 +1,505 @@ +// Command yaegi-check replicates the Traefik Plugin Catalog analyzer +// (piceus)'s yaegiMiddlewareCheck for this plugin. It loads the plugin +// package under Yaegi exactly as the catalog and Traefik's own plugin +// loader do: interpret the source tree, evaluate CreateConfig(), decode +// the plugin's real .traefik.yml testData into the resulting Config +// value, then call the plugin's New constructor by reflection from +// outside the interpreter. It then exercises the returned handler with +// two real HTTP requests, proving ServeHTTP, auth, the model registry, +// statusTrackingWriter, and the gateway's logger all run under Yaegi — +// not merely that the package's imports resolve. +// +// Any failure — a Yaegi-incompatible construct in the plugin's stdlib +// usage, a constructor error, a nil handler, or a wrong response from the +// live handler — exits non-zero: this is the gate the plugin must pass +// before it can list in the Traefik Plugin Catalog. +// +// This module is intentionally separate from the plugin module +// (llmgw-yaegi-check, not github.com/lukaszraczylo/traefik-llmgateway): +// the plugin module must stay free of any non-stdlib dependency, since +// Yaegi interprets it with only the Go standard library available: a +// third-party import in the plugin module's own go.mod would break under +// interpretation even though `go build` never notices. +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/traefik/yaegi/interp" + "github.com/traefik/yaegi/stdlib" +) + +// testDataUserAPIKey and testDataWantModel are drawn directly from the +// plugin's own .traefik.yml testData block (users.inline[0].apiKey and +// providers.openai.models[0]) — see exerciseHandler. +const ( + testDataUserAPIKey = "test-user-key" + testDataWantModel = "gpt-test" +) + +// excludedTopLevelDirs lists repo-root directories the GOPATH copy must +// never include: build tooling, integration fixtures, planning docs, and +// VCS metadata have nothing to do with the plugin package Yaegi imports. +var excludedTopLevelDirs = map[string]bool{ + "tools": true, + "integration": true, + ".superpowers": true, + "docs": true, + ".git": true, +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "yaegi-check: FAIL:", err) + os.Exit(1) + } + fmt.Println("yaegi-check: OK") +} + +// run resolves the plugin's repo root (os.Args[1], defaulting to the +// current directory), builds a temporary GOPATH containing a copy of its +// source, interprets the plugin package under Yaegi, and exercises the +// constructed handler. It returns the first error encountered, wrapped +// with enough context to diagnose which stage failed. +func run() error { + repoRoot := "." + if len(os.Args) > 1 { + repoRoot = os.Args[1] + } + repoRoot, err := filepath.Abs(repoRoot) + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + + modulePath, err := readModulePath(filepath.Join(repoRoot, "go.mod")) + if err != nil { + return fmt.Errorf("read module path: %w", err) + } + + gopath, err := os.MkdirTemp("", "llmgw-yaegi-check-*") + if err != nil { + return fmt.Errorf("create temp GOPATH: %w", err) + } + defer os.RemoveAll(gopath) //nolint:errcheck // best-effort cleanup of a temp dir + + pkgDir := filepath.Join(gopath, "src", modulePath) + if err = copyRepoSource(repoRoot, pkgDir); err != nil { + return fmt.Errorf("copy repo source into GOPATH: %w", err) + } + + pkgName, err := readPackageName(pkgDir) + if err != nil { + return fmt.Errorf("read plugin package name: %w", err) + } + + i := interp.New(interp.Options{GoPath: gopath}) + if err = i.Use(stdlib.Symbols); err != nil { + return fmt.Errorf("interp.Use(stdlib.Symbols): %w", err) + } + if _, err = i.Eval(`import "` + modulePath + `"`); err != nil { + return fmt.Errorf("import plugin package under yaegi: %w", err) + } + + cfgVal, err := i.Eval(pkgName + ".CreateConfig()") + if err != nil { + return fmt.Errorf("call %s.CreateConfig() under yaegi: %w", pkgName, err) + } + if !cfgVal.CanInterface() { + return fmt.Errorf("%s.CreateConfig() result cannot be used via reflect.Value.Interface", pkgName) + } + + testData, err := extractTraefikYAMLTestData(filepath.Join(repoRoot, ".traefik.yml")) + if err != nil { + return fmt.Errorf("extract .traefik.yml testData: %w", err) + } + testDataJSON, err := json.Marshal(testData) + if err != nil { + return fmt.Errorf("marshal testData: %w", err) + } + if err = json.Unmarshal(testDataJSON, cfgVal.Interface()); err != nil { + return fmt.Errorf("decode .traefik.yml testData into the interpreted Config: %w", err) + } + + newVal, err := i.Eval(pkgName + ".New") + if err != nil { + return fmt.Errorf("resolve %s.New under yaegi: %w", pkgName, err) + } + if newVal.Kind() != reflect.Func { + return fmt.Errorf("%s.New is not a function (kind %s)", pkgName, newVal.Kind()) + } + + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + results := newVal.Call([]reflect.Value{ + reflect.ValueOf(context.Background()), + reflect.ValueOf(next), + cfgVal, + reflect.ValueOf("llmgw-check"), + }) + if len(results) != 2 { + return fmt.Errorf("%s.New returned %d values, want 2 (http.Handler, error)", pkgName, len(results)) + } + if errVal := results[1]; !errVal.IsNil() { + return fmt.Errorf("%s.New returned an error: %v", pkgName, errVal.Interface()) + } + handlerVal := results[0] + if handlerVal.IsNil() { + return fmt.Errorf("%s.New returned a nil handler", pkgName) + } + handler, ok := handlerVal.Interface().(http.Handler) + if !ok { + return fmt.Errorf("%s.New's returned value does not implement http.Handler", pkgName) + } + + return exerciseHandler(handler) +} + +// exerciseHandler runs two real requests against the interpreted +// handler, drawn from the plugin's own .traefik.yml testData: an +// authenticated GET /v1/models, which must return the configured +// testDataWantModel; and the same route without credentials, which must +// be refused with 401 — proving ServeHTTP, auth.identify, the model +// registry, statusTrackingWriter, and the gateway's logger all execute +// for real under Yaegi, not merely that the package's imports resolve. +func exerciseHandler(handler http.Handler) error { + authedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + authedReq.Header.Set("Authorization", "Bearer "+testDataUserAPIKey) + authedRec := httptest.NewRecorder() + handler.ServeHTTP(authedRec, authedReq) + if authedRec.Code != http.StatusOK { + return fmt.Errorf("GET /v1/models with the testData user key: status = %d, want 200, body=%s", authedRec.Code, authedRec.Body.String()) + } + if !strings.Contains(authedRec.Body.String(), testDataWantModel) { + return fmt.Errorf("GET /v1/models body does not contain %q: %s", testDataWantModel, authedRec.Body.String()) + } + + unauthedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + unauthedRec := httptest.NewRecorder() + handler.ServeHTTP(unauthedRec, unauthedReq) + if unauthedRec.Code != http.StatusUnauthorized { + return fmt.Errorf("GET /v1/models without a key: status = %d, want 401, body=%s", unauthedRec.Code, unauthedRec.Body.String()) + } + return nil +} + +// readModulePath returns the module path declared by goModPath's +// "module" directive. +func readModulePath(goModPath string) (string, error) { + f, err := os.Open(goModPath) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck // read-side close; nothing actionable on failure + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if after, ok := strings.CutPrefix(line, "module "); ok { + return strings.TrimSpace(after), nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no module directive found in %s", goModPath) +} + +// readPackageName returns the package name declared by the first +// non-test .go file it finds directly inside dir. The plugin is a single +// root package, so any one of its files' declarations suffices. +func readPackageName(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + pkgName, err := scanPackageDecl(filepath.Join(dir, name)) + if err != nil { + return "", err + } + if pkgName != "" { + return pkgName, nil + } + } + return "", fmt.Errorf("no package declaration found in %s", dir) +} + +// scanPackageDecl reads path's leading "package X" line, if any. +func scanPackageDecl(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() //nolint:errcheck // read-side close; nothing actionable on failure + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if after, ok := strings.CutPrefix(line, "package "); ok { + return strings.TrimSpace(after), nil + } + } + return "", scanner.Err() +} + +// copyRepoSource copies repoRoot into destDir, skipping every directory +// named in excludedTopLevelDirs. destDir plays the role of +// GOPATH/src/{modulePath} for the interpreter: Yaegi's source loader +// resolves the plugin's own import path by walking GOPATH exactly as the +// standard toolchain would for a pre-modules GOPATH-mode build. +func copyRepoSource(repoRoot, destDir string) error { + entries, err := os.ReadDir(repoRoot) + if err != nil { + return err + } + if err := os.MkdirAll(destDir, 0o750); err != nil { + return err + } + for _, e := range entries { + if excludedTopLevelDirs[e.Name()] { + continue + } + src := filepath.Join(repoRoot, e.Name()) + dst := filepath.Join(destDir, e.Name()) + if e.IsDir() { + if err := copyDir(src, dst); err != nil { + return err + } + continue + } + if err := copyFile(src, dst); err != nil { + return err + } + } + return nil +} + +// copyDir recursively copies src to dst. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o750) + } + return copyFile(path, target) + }) +} + +// copyFile copies src to dst, creating dst's parent directory if needed. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() //nolint:errcheck // read-side close; nothing actionable on failure + + if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() //nolint:errcheck // Close's error is checked explicitly below + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +// yamlLine is one non-blank, non-comment line of a YAML file, with +// leading whitespace stripped and recorded as indent. +type yamlLine struct { + text string + indent int +} + +// extractTraefikYAMLTestData reads path (the plugin's .traefik.yml) and +// decodes its top-level "testData:" block into a generic +// map[string]any/[]any/scalar tree, using a small hand-rolled, +// indentation-based YAML subset parser: just enough for this one file's +// shape (nested maps, lists of scalars, lists of flat maps, "{}"/"[]" +// empty collections), not a general YAML implementation. It exists only +// in this harness module — the plugin module never parses YAML itself, +// Traefik does that before handing the plugin its already-decoded Config. +func extractTraefikYAMLTestData(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + lines := tokenizeYAMLLines(string(raw)) + block := extractTestDataBlock(lines) + if block == nil { + return nil, fmt.Errorf("%s has no top-level testData: block", path) + } + pos := 0 + v := parseBlock(block, &pos, block[0].indent) + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s testData: block is not a map", path) + } + return m, nil +} + +// tokenizeYAMLLines splits raw into non-blank, non-comment yamlLines, +// each carrying its own leading-space count as indent. +func tokenizeYAMLLines(raw string) []yamlLine { + var out []yamlLine + for _, ln := range strings.Split(raw, "\n") { + trimmedRight := strings.TrimRight(ln, " \t\r") + content := strings.TrimLeft(trimmedRight, " ") + if content == "" || strings.HasPrefix(content, "#") { + continue + } + out = append(out, yamlLine{indent: len(trimmedRight) - len(content), text: content}) + } + return out +} + +// extractTestDataBlock returns the lines nested under a top-level (indent +// 0) "testData:" line, or nil if lines has none. +func extractTestDataBlock(lines []yamlLine) []yamlLine { + for i, l := range lines { + if l.indent != 0 || l.text != "testData:" { + continue + } + var block []yamlLine + for j := i + 1; j < len(lines) && lines[j].indent > 0; j++ { + block = append(block, lines[j]) + } + return block + } + return nil +} + +// parseBlock parses the run of lines starting at *pos that share indent, +// dispatching to parseList when the first such line is a "-" item and to +// parseMap otherwise. +func parseBlock(lines []yamlLine, pos *int, indent int) any { + if *pos >= len(lines) || lines[*pos].indent != indent { + return map[string]any{} + } + if strings.HasPrefix(lines[*pos].text, "-") { + return parseList(lines, pos, indent) + } + return parseMap(lines, pos, indent) +} + +// parseMap consumes every consecutive "key: value" line at indent, +// recursing into parseBlock for a key whose value is empty (a nested +// block follows on deeper-indented lines). +func parseMap(lines []yamlLine, pos *int, indent int) map[string]any { + m := map[string]any{} + for *pos < len(lines) && lines[*pos].indent == indent { + key, val, _ := strings.Cut(lines[*pos].text, ":") + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + *pos++ + if val != "" { + m[key] = collectionOrScalar(val) + continue + } + if *pos < len(lines) && lines[*pos].indent > indent { + m[key] = parseBlock(lines, pos, lines[*pos].indent) + } else { + m[key] = map[string]any{} + } + } + return m +} + +// parseList consumes every consecutive "- ..." line at indent. A "-" +// item containing a colon starts a flat map (its own "key: value" plus +// every following line indented at least indent+2, until a line back at +// indent or shallower); any other item is a bare scalar. +func parseList(lines []yamlLine, pos *int, indent int) []any { + var out []any + for *pos < len(lines) && lines[*pos].indent == indent && strings.HasPrefix(lines[*pos].text, "-") { + item := strings.TrimSpace(strings.TrimPrefix(lines[*pos].text, "-")) + if item == "" { + *pos++ + continue + } + if !strings.Contains(item, ":") { + out = append(out, parseScalar(item)) + *pos++ + continue + } + + m := map[string]any{} + key, val, _ := strings.Cut(item, ":") + m[strings.TrimSpace(key)] = collectionOrScalar(strings.TrimSpace(val)) + *pos++ + + childIndent := indent + 2 + for *pos < len(lines) && lines[*pos].indent >= childIndent { + k, v, _ := strings.Cut(lines[*pos].text, ":") + m[strings.TrimSpace(k)] = collectionOrScalar(strings.TrimSpace(v)) + *pos++ + } + out = append(out, m) + } + return out +} + +// collectionOrScalar maps a "key: value" line's already-trimmed val to +// an empty map/slice for "{}"/"[]", or to parseScalar(val) otherwise. It +// does not recurse into a further nested block — no key in this file's +// testData needs a map/list value inside a list item's own fields, and +// this harness parses only what .traefik.yml actually contains. +func collectionOrScalar(val string) any { + switch val { + case "{}": + return map[string]any{} + case "[]": + return []any{} + default: + return parseScalar(val) + } +} + +// parseScalar converts a YAML scalar token to a bool, int64, float64, or +// string, in that preference order, stripping a matching pair of quotes +// first. +func parseScalar(s string) any { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + switch s { + case "true": + return true + case "false": + return false + } + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return n + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + return s +}