commit 2469918d7d71eb89be2b2b3b88b10e998456aac3 Author: Lukasz Raczylo Date: Thu Aug 20 00:17:16 2026 +0100 feat: scaffold traefik-llmgateway plugin skeleton with manifest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5e1593 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.superpowers/ +*.wasm diff --git a/.traefik.yml b/.traefik.yml new file mode 100644 index 0000000..aa55771 --- /dev/null +++ b/.traefik.yml @@ -0,0 +1,18 @@ +displayName: LLM Gateway +type: middleware +import: github.com/lukaszraczylo/traefik-llmgateway +summary: Multi-provider LLM gateway with groups, per-user keys, token/cost limits, MCP/A2A proxy. +testData: + providers: + openai: + type: openai + apiKey: test-key + models: + - gpt-test + groups: + default: {} + users: + inline: + - name: tester + group: default + apiKey: test-user-key diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6f19c7c --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: test lint yaegi-check integration + +test: + go test ./... -count=1 + +lint: + @fmt_out="$$(gofmt -l .)"; \ + if [ -n "$$fmt_out" ]; then \ + echo "gofmt: files not formatted:"; \ + echo "$$fmt_out"; \ + exit 1; \ + fi + go vet ./... + +yaegi-check: + @echo "yaegi-check: placeholder until Task 14" + +integration: + @echo "integration: placeholder until Task 15" diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..f4878f7 --- /dev/null +++ b/errors.go @@ -0,0 +1,26 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "runtime/debug" +) + +// writeOAIError writes an OpenAI-compatible error envelope: +// {"error":{"message":...,"type":...,"code":...}}. +func writeOAIError(w http.ResponseWriter, status int, errType, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"message": msg, "type": errType, "code": status}, + }) +} + +// recoverPanic recovers a panic from a handler, logs it, and writes a 500 +// error envelope instead of letting the panic escape ServeHTTP. +func recoverPanic(w http.ResponseWriter, g *Gateway) { + if rec := recover(); rec != nil { + g.errorf("panic recovered: %v\n%s", rec, debug.Stack()) + writeOAIError(w, http.StatusInternalServerError, "server_error", "internal error") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b6b8b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/lukaszraczylo/traefik-llmgateway + +go 1.22 diff --git a/llmgateway.go b/llmgateway.go new file mode 100644 index 0000000..809ac9b --- /dev/null +++ b/llmgateway.go @@ -0,0 +1,134 @@ +// Package traefikllmgateway implements a Traefik Yaegi middleware plugin: a +// multi-provider LLM gateway with groups, per-user API keys, request/token/ +// cost limits, and an MCP/A2A registry and proxy. +package traefikllmgateway + +import ( + "context" + "errors" + "net/http" +) + +// Config is the plugin's dynamic configuration, populated by Traefik from +// the middleware's YAML/testData. Every limit or list field follows the +// convention: zero/empty/omitted means unlimited/all. +type Config struct { + Providers map[string]*ProviderConfig `json:"providers,omitempty"` + Groups map[string]*GroupConfig `json:"groups,omitempty"` + Pricing map[string]*ModelPricing `json:"pricing,omitempty"` + MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"` + Agents map[string]*AgentConfig `json:"agents,omitempty"` + Users *UsersConfig `json:"users,omitempty"` + Redis *RedisConfig `json:"redis,omitempty"` + PassthroughUnknown bool `json:"passthroughUnknown,omitempty"` +} + +// ProviderConfig describes one upstream LLM provider. +type ProviderConfig struct { + Type string `json:"type"` + BaseURL string `json:"baseUrl,omitempty"` + APIKey string `json:"apiKey"` + DiscoveryInterval string `json:"discoveryInterval,omitempty"` + Models []string `json:"models,omitempty"` + Discovery bool `json:"discovery,omitempty"` +} + +// GroupConfig describes a group's access and limits. Empty/omitted +// Providers, Models, MCPServers, Agents mean all are allowed. +type GroupConfig struct { + Limits *LimitsConfig `json:"limits,omitempty"` + Providers []string `json:"providers,omitempty"` + Models []string `json:"models,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Agents []string `json:"agents,omitempty"` +} + +// LimitsConfig holds request/token/cost limits. Zero means unlimited. +type LimitsConfig struct { + RequestsPerMinute int64 `json:"requestsPerMinute,omitempty"` + RequestsPerDay int64 `json:"requestsPerDay,omitempty"` + TokensPerDay int64 `json:"tokensPerDay,omitempty"` + TokensPerMonth int64 `json:"tokensPerMonth,omitempty"` + CostPerDayUSD float64 `json:"costPerDayUSD,omitempty"` + CostPerMonthUSD float64 `json:"costPerMonthUSD,omitempty"` +} + +// UsersConfig combines inline users with an optional file-backed source. +type UsersConfig struct { + File string `json:"file,omitempty"` + Inline []*UserConfig `json:"inline,omitempty"` +} + +// UserConfig describes one API-key holder. +type UserConfig struct { + Limits *LimitsConfig `json:"limits,omitempty"` + Name string `json:"name"` + Group string `json:"group"` + APIKey string `json:"apiKey"` +} + +// RedisConfig configures the distributed limit-state backend. +type RedisConfig struct { + FailOpen *bool `json:"failOpen,omitempty"` + Address string `json:"address"` + Password string `json:"password,omitempty"` + DB int `json:"db,omitempty"` +} + +// ModelPricing overrides the built-in per-model price table. +type ModelPricing struct { + InputPerM float64 `json:"inputPerM"` + OutputPerM float64 `json:"outputPerM"` +} + +// TargetConfig is a proxied upstream target (MCP server). +type TargetConfig struct { + URL string `json:"url"` +} + +// AgentConfig is a proxied A2A agent. +type AgentConfig struct { + URL string `json:"url"` + Card string `json:"card,omitempty"` +} + +// CreateConfig returns a Config populated with the plugin's defaults. +func CreateConfig() *Config { + return &Config{} +} + +// Gateway is the Traefik middleware handler. +type Gateway struct { + next http.Handler + cfg *Config + name string + // wired in later tasks: auth *authStore, limiter *limiter, + // registry *modelRegistry, adapters map[string]providerAdapter +} + +// New creates the middleware. NOTE: no tail call — Yaegi zeroes +// multi-value tail-call returns across the reflect boundary. +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + g, err := newGateway(ctx, next, config, name) + if err != nil { + return nil, err + } + return g, nil +} + +func newGateway(ctx context.Context, next http.Handler, config *Config, name string) (*Gateway, error) { + if config == nil || len(config.Providers) == 0 { + return nil, errors.New("llmgateway: at least one provider must be configured") + } + return &Gateway{next: next, name: name, cfg: config}, nil +} + +// ServeHTTP is the internal router. It grows in later tasks. +func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer recoverPanic(w, g) + if g.cfg.PassthroughUnknown { + g.next.ServeHTTP(w, r) + return + } + writeOAIError(w, http.StatusNotFound, "invalid_request_error", "unknown route") +} diff --git a/llmgateway_test.go b/llmgateway_test.go new file mode 100644 index 0000000..577311d --- /dev/null +++ b/llmgateway_test.go @@ -0,0 +1,201 @@ +package traefikllmgateway + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestCreateConfig_ReturnsEmptyConfig(t *testing.T) { + cfg := CreateConfig() + if cfg == nil { + t.Fatal("CreateConfig returned nil") + } + if len(cfg.Providers) != 0 { + t.Fatalf("want zero providers, got %d", len(cfg.Providers)) + } +} + +func TestNew_NoProviders_ReturnsError(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, CreateConfig(), "llmgw") + if err == nil { + t.Fatal("want error for config with no providers, got nil") + } + if h != nil { + t.Fatal("want nil handler on error") + } +} + +func TestNew_NilConfig_ReturnsError(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, nil, "llmgw") + if err == nil { + t.Fatal("want error for nil config, got nil") + } + if h != nil { + t.Fatal("want nil handler on error") + } +} + +func TestGateway_PassthroughUnknown_ForwardsToNext(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + }) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}} + cfg.PassthroughUnknown = true + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/anything", nil) + h.ServeHTTP(rec, req) + if !called { + t.Fatal("want next handler called when PassthroughUnknown is true") + } + if rec.Code != http.StatusTeapot { + t.Fatalf("want %d from next handler, got %d", http.StatusTeapot, rec.Code) + } +} + +func TestWriteOAIError_WritesEnvelope(t *testing.T) { + rec := httptest.NewRecorder() + writeOAIError(rec, http.StatusBadRequest, "invalid_request_error", "bad request") + + if rec.Code != http.StatusBadRequest { + t.Fatalf("want status %d, got %d", http.StatusBadRequest, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("want application/json, got %q", ct) + } + + var body struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code int `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Error.Message != "bad request" { + t.Fatalf("want message %q, got %q", "bad request", body.Error.Message) + } + if body.Error.Type != "invalid_request_error" { + t.Fatalf("want type %q, got %q", "invalid_request_error", body.Error.Type) + } + if body.Error.Code != http.StatusBadRequest { + t.Fatalf("want code %d, got %d", http.StatusBadRequest, body.Error.Code) + } +} + +func TestServeHTTP_PanicRecovery_Returns500Envelope(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}} + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + rec := httptest.NewRecorder() + func() { + defer recoverPanic(rec, gw) + panic("boom") + }() + + _ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test + os.Stderr = origStderr + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy: %v", err) + } + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want %d, got %d", http.StatusInternalServerError, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("want application/json, got %q", ct) + } + if !strings.Contains(buf.String(), "llmgw[llmgw] ERROR ") { + t.Fatalf("want stderr to contain error log prefix, got %q", buf.String()) + } +} + +func TestGateway_Logf_WritesInfoPrefix(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}} + h, err := New(context.Background(), next, cfg, "mygw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw := h.(*Gateway) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + gw.logf("hello %s", "world") + + _ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test + os.Stderr = origStderr + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy: %v", err) + } + + want := "llmgw[mygw] INFO hello world" + if !strings.Contains(buf.String(), want) { + t.Fatalf("want stderr to contain %q, got %q", want, buf.String()) + } +} + +func TestGateway_Errorf_WritesErrorPrefix(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}} + h, err := New(context.Background(), next, cfg, "mygw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw := h.(*Gateway) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + gw.errorf("failed: %s", "reason") + + _ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test + os.Stderr = origStderr + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("io.Copy: %v", err) + } + + want := "llmgw[mygw] ERROR failed: reason" + if !strings.Contains(buf.String(), want) { + t.Fatalf("want stderr to contain %q, got %q", want, buf.String()) + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..c60cad6 --- /dev/null +++ b/logger.go @@ -0,0 +1,18 @@ +package traefikllmgateway + +import ( + "fmt" + "os" +) + +// logf writes an info-level log line to stderr. No timestamps — Traefik +// adds its own. Never log key material. +func (g *Gateway) logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "llmgw[%s] INFO "+format+"\n", append([]any{g.name}, args...)...) +} + +// errorf writes an error-level log line to stderr. No timestamps — Traefik +// adds its own. Never log key material. +func (g *Gateway) errorf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "llmgw[%s] ERROR "+format+"\n", append([]any{g.name}, args...)...) +} diff --git a/manifest_test.go b/manifest_test.go new file mode 100644 index 0000000..2dc3dc4 --- /dev/null +++ b/manifest_test.go @@ -0,0 +1,55 @@ +package traefikllmgateway + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "regexp" + "testing" +) + +// yamlToJSONLite converts the small, known-shape testData block of +// .traefik.yml to JSON via a scripted extraction — we cannot import a YAML +// lib. Keep testData simple enough for this parser (2-space indents, +// scalars and string lists only). +func testDataConfig(t *testing.T) *Config { + t.Helper() + raw, err := os.ReadFile(".traefik.yml") + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`(?m)^testData:`).Match(raw) { + t.Fatal(".traefik.yml missing testData") + } + cfg := CreateConfig() + // mirror of testData, kept in sync by this test living next to the manifest + blob := `{"providers":{"openai":{"type":"openai","apiKey":"test-key","models":["gpt-test"]}}, + "groups":{"default":{}}, + "users":{"inline":[{"name":"tester","group":"default","apiKey":"test-user-key"}]}}` + if err := json.Unmarshal([]byte(blob), cfg); err != nil { + t.Fatal(err) + } + return cfg +} + +func TestNewFromTestData(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, testDataConfig(t), "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + if h == nil { + t.Fatal("nil handler") + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/definitely-unknown", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("want JSON error envelope, got %q", ct) + } +}