mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
Counters (always on when admin is enabled): - last seen per user and group, written asynchronously after admission, throttled to one write per scope per minute per replica - hourly provider and model attempts, failures and timeouts - failovers per provider, rejections per hour, and unpriced (402) refusals - cache hits, misses and cost saved - request counts per target and caller Opt-in counters under admin.stats (both default off): - userModel: usage per user and model - latency: latency histograms per provider and model All counters ride the existing Redis batches. A test measures three round trips per request with defaults and with every statistic on. A cache hit takes two. A model request is no longer counted for failed failover candidates. New admin endpoints: - usage/series and usage/totals - performance, with percentiles from the histograms - catalog, with the price billing actually uses and its source - consumers, with key source, grants and model access - config, with secrets redacted The overview gains features and a pricing summary. Usage gains lastSeen. usage/models gains offset and more detail fields. Reads are chunked at 1600 keys per round trip and capped at 64000 keys per request. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
367 lines
15 KiB
Go
367 lines
15 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Target kinds handleTargetProxy and resolveTarget dispatch on, selecting
|
|
// which of Config.MCPServers/Config.Agents to consult and which of
|
|
// group.allowsMCP/group.allowsAgent authorizes the request.
|
|
const (
|
|
targetKindMCP = "mcp"
|
|
targetKindAgent = "a2a"
|
|
)
|
|
|
|
// defaultAgentCardPath is the well-known A2A agent-card path used for a
|
|
// listed agent whose AgentConfig.Card is empty.
|
|
const defaultAgentCardPath = "/.well-known/agent-card.json"
|
|
|
|
// scopeKindAgent is the limitScope.kind (and admin-API scope-kind) string
|
|
// an A2A agent target's per-target request counters use (Feature B,
|
|
// v0.21) — deliberately NOT targetKindAgent ("a2a", this file's own
|
|
// URL-routing convention): "agent" is the vocabulary GET /admin/api/targets
|
|
// (admin.go) and the webui's MCP & Agents panel use. The split matters for
|
|
// collision-safety too: windowKey (limits.go) embeds kind as the counter
|
|
// key's own leading segment, so an MCP server and an A2A agent configured
|
|
// with the identical name — or either one sharing a name with a user or
|
|
// group — can never share a counter regardless of what any of them are
|
|
// called; kind, not id alone, is what makes a scope unique.
|
|
const scopeKindAgent = "agent"
|
|
|
|
// targetScopeKind maps handleTargetProxy's routing kind (targetKindMCP or
|
|
// targetKindAgent) to the accounting scope kind its per-target request
|
|
// counters use: identical for MCP (targetKindMCP is already "mcp"), but
|
|
// remapped to scopeKindAgent ("agent") for an A2A target.
|
|
func targetScopeKind(kind string) string {
|
|
if kind == targetKindAgent {
|
|
return scopeKindAgent
|
|
}
|
|
return kind
|
|
}
|
|
|
|
// mcpServerListing is one entry of GET /v1/mcp/servers: the configured
|
|
// name and the gateway-relative URL a client proxies requests to it
|
|
// through.
|
|
type mcpServerListing struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// agentListing is one entry of GET /v1/agents: the configured name, the
|
|
// gateway-relative proxy URL, and the gateway-relative agent-card URL.
|
|
type agentListing struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Card string `json:"card"`
|
|
}
|
|
|
|
// handleMCPServers implements GET /v1/mcp/servers: grp's visible MCP
|
|
// servers, filtered by group.allowsMCP and sorted by name for a
|
|
// deterministic response.
|
|
func (g *Gateway) handleMCPServers(w http.ResponseWriter, grp *group) {
|
|
g.maybeSweepTargetHealth()
|
|
names := make([]string, 0, len(g.cfg.MCPServers))
|
|
for name := range g.cfg.MCPServers {
|
|
if grp.allowsMCP(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
|
|
servers := make([]mcpServerListing, len(names))
|
|
for i, name := range names {
|
|
servers[i] = mcpServerListing{Name: name, URL: "/mcp/" + name}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"servers": servers}) // headers already committed; nothing useful to do on encode failure
|
|
}
|
|
|
|
// handleAgents implements GET /v1/agents: grp's visible A2A agents,
|
|
// filtered by group.allowsAgent and sorted by name for a deterministic
|
|
// response. Each entry's card URL is the agent's own gateway-relative
|
|
// proxy URL plus its configured card path, defaulting to
|
|
// defaultAgentCardPath when AgentConfig.Card is empty.
|
|
func (g *Gateway) handleAgents(w http.ResponseWriter, grp *group) {
|
|
g.maybeSweepTargetHealth()
|
|
names := make([]string, 0, len(g.cfg.Agents))
|
|
for name := range g.cfg.Agents {
|
|
if grp.allowsAgent(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
|
|
agents := make([]agentListing, len(names))
|
|
for i, name := range names {
|
|
cardPath := g.cfg.Agents[name].Card
|
|
if cardPath == "" {
|
|
cardPath = defaultAgentCardPath
|
|
}
|
|
agents[i] = agentListing{Name: name, URL: "/a2a/" + name, Card: "/a2a/" + name + cardPath}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"agents": agents}) // headers already committed; nothing useful to do on encode failure
|
|
}
|
|
|
|
// targetRoute splits path into a target name and its rest path for the
|
|
// "/{prefix}/{name}/{rest...}" MCP/A2A convention (prefix is
|
|
// targetKindMCP or targetKindAgent), reusing passthroughRoute's own
|
|
// EscapedPath-based, traversal-safe splitting twice: once to peel prefix
|
|
// off path, once more on what is left to peel name off rest. ok is false
|
|
// when path's first segment is not exactly prefix, or when no name
|
|
// segment follows it — a bare "/mcp" or "/mcp/" carries no target name to
|
|
// route to.
|
|
func targetRoute(path, prefix string) (name, rest string, ok bool) {
|
|
first, remainder, matched := passthroughRoute(path)
|
|
if !matched || first != prefix {
|
|
return "", "", false
|
|
}
|
|
return passthroughRoute("/" + remainder)
|
|
}
|
|
|
|
// resolveTarget looks up name in the Gateway's MCP-server or agent
|
|
// registry, selected by kind (targetKindMCP or targetKindAgent). known is
|
|
// false when no such name is configured at all; allowed reports whether
|
|
// grp's glob patterns permit it, meaningful only when known is true.
|
|
func (g *Gateway) resolveTarget(kind, name string, grp *group) (targetURL string, allowed, known bool) {
|
|
switch kind {
|
|
case targetKindMCP:
|
|
tc, ok := g.cfg.MCPServers[name]
|
|
if !ok {
|
|
return "", false, false
|
|
}
|
|
return tc.URL, grp.allowsMCP(name), true
|
|
case targetKindAgent:
|
|
ac, ok := g.cfg.Agents[name]
|
|
if !ok {
|
|
return "", false, false
|
|
}
|
|
return ac.URL, grp.allowsAgent(name), true
|
|
default:
|
|
return "", false, false
|
|
}
|
|
}
|
|
|
|
// handleTargetProxy implements the MCP/A2A target proxy routes:
|
|
// "/mcp/{name}/{rest...}" and "/a2a/{name}/{rest...}" reverse-proxy rest,
|
|
// verbatim, to name's configured target URL (Config.MCPServers[name].URL
|
|
// or Config.Agents[name].URL, per kind). It shares its proxy mechanics
|
|
// with handlePassthrough via proxyUpstream — hop-by-hop header stripping,
|
|
// the client's own gateway credential stripped, SSE-safe incremental
|
|
// flushing, a dead upstream mapped to 502, a canceled client context
|
|
// logged rather than surfaced. Unlike handlePassthrough, no upstream
|
|
// credential is injected (proxyUpstream's injectAuth argument is nil):
|
|
// MCP servers and A2A agents are in-cluster targets that trust the
|
|
// gateway's network position, not a per-provider API key the gateway
|
|
// holds on the caller's behalf. Accounting is likewise passthrough-only —
|
|
// a target request is counted by checkAndCount below (the caller's own
|
|
// user/group/total scopes) and, additionally, attributed to name's own
|
|
// per-target scope (limiter.countTargetRequest, Feature B v0.21) — but its
|
|
// response is never teed off for token/cost usage extraction, for any of
|
|
// those scopes.
|
|
//
|
|
// Checks run: unknown name (404) before group authorization (403) before
|
|
// capability/path checks (Upgrade→501, traversal→400) before rate limits
|
|
// (429/503) — matching handlePassthrough's own order (403 → 501 → 400 →
|
|
// limit), with the unknown-name check slotted in first since a target
|
|
// proxy request can fail that in a way native passthrough never can. A
|
|
// request rejected on any check before checkAndCount must never burn the
|
|
// caller's or their group's rate-limit quota for a call that was never
|
|
// going to reach the upstream.
|
|
//
|
|
// Once admitted, limiter.countTargetRequest (limits.go) additionally
|
|
// attributes the request to name's own per-target scope (Feature B,
|
|
// v0.21) — requests only, same as the caller's own user/group counters:
|
|
// GET /admin/api/targets (admin.go) reads these back for the webui's MCP &
|
|
// Agents panel. This scope carries no limit of its own (no config surface
|
|
// added this round) and so can never itself cause a 429/503; it is purely
|
|
// additive visibility on top of the user/group/total admission decision
|
|
// checkAndCount already made above.
|
|
func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *user, grp *group, kind, name, rest string) {
|
|
targetURL, allowed, known := g.resolveTarget(kind, name, grp)
|
|
if !known {
|
|
writeOAIError(w, http.StatusNotFound, "invalid_request_error", "unknown "+kind+" target")
|
|
return
|
|
}
|
|
if !allowed {
|
|
writeOAIError(w, http.StatusForbidden, "invalid_request_error", kind+" access denied")
|
|
return
|
|
}
|
|
|
|
if r.Header.Get("Upgrade") != "" {
|
|
writeOAIError(w, http.StatusNotImplemented, "invalid_request_error", "websocket/upgrade passthrough not supported")
|
|
return
|
|
}
|
|
if hasTraversalSegment(rest) {
|
|
writeOAIError(w, http.StatusBadRequest, "invalid_request_error", "invalid path")
|
|
return
|
|
}
|
|
|
|
scopes := withTotalScope(buildLimitScopes(u, grp))
|
|
if violation := g.limiter.checkAndCount(scopes); violation != nil {
|
|
g.recordLimitEvent(scopes, kind, violation) // F3 hook 1 (v0.3 dashboard task): kind is already "mcp"/"a2a"
|
|
writeLimitViolation(w, violation)
|
|
return
|
|
}
|
|
// P9 fix (admin dashboard redesign verify round): last-seen only for
|
|
// an admitted request — recordLastSeen's own doc comment (limits.go).
|
|
g.limiter.recordLastSeen(scopes, g.limiter.now())
|
|
// Target x caller (always-on-with-admin, DECISIONS): u.name identifies
|
|
// the caller whose own credential authorized this proxy — see
|
|
// countTargetRequestBy's own doc comment (limits.go) for the id shape
|
|
// and admin-stats gating.
|
|
g.limiter.countTargetRequestBy(u.name, targetScopeKind(kind), name)
|
|
|
|
upstreamURL, err := buildUpstreamTargetURL(targetURL, rest, r.URL.RawQuery)
|
|
if err != nil {
|
|
// targetURL is operator-configured and already validated at
|
|
// construction (validateTargetURLs) — unreachable in practice,
|
|
// kept as an explicit guard rather than trusting that invariant
|
|
// silently.
|
|
writeOAIError(w, http.StatusInternalServerError, "invalid_request_error", "invalid target configuration")
|
|
return
|
|
}
|
|
|
|
// feat/target-health: passive recording (target_health.go) — latency
|
|
// measured around the whole proxyUpstream call, matching what an
|
|
// active probe of the same target measures. See
|
|
// recordTargetProxyHealth's own doc comment for the exact
|
|
// success/failure rule.
|
|
start := time.Now()
|
|
result, ok := g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")", g.targetTimeout)
|
|
g.recordTargetProxyHealth(kind, name, targetURL, result, ok, time.Since(start))
|
|
g.recordProxyEvent(scopes, name, kind, result) // F3 hook 3 (v0.3 dashboard task)
|
|
}
|
|
|
|
// buildUpstreamTargetURL joins targetURL (an MCP server or A2A agent's own
|
|
// configured base, already validated at construction by
|
|
// validateTargetURL) with rest (handleTargetProxy's own path remainder,
|
|
// already checked for a traversal segment by its caller) using
|
|
// url.URL.JoinPath rather than plain string concatenation (F7, review
|
|
// round 3, 2026-09): the prior "+ \"/\" + rest" produced a doubled slash
|
|
// whenever targetURL's own path already ended in "/" (e.g. configured as
|
|
// "http://x/mcp/"), and, worse, a targetURL carrying its own query string
|
|
// — the only way TargetConfig/AgentConfig can pass a credential to an
|
|
// upstream today, since neither carries a header field — got that query
|
|
// glued onto the wrong side of "?" + the request's own RawQuery,
|
|
// producing something like "...?token=abc/foo" or two "?" characters and
|
|
// corrupting the token federation was relying on. The target's own query
|
|
// string is preserved (JoinPath never touches RawQuery) and merged with
|
|
// the incoming request's own query (mergeQueryStrings, below) — neither
|
|
// silently overwrites the other.
|
|
func buildUpstreamTargetURL(targetURL, rest, requestRawQuery string) (string, error) {
|
|
base, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
joined := base
|
|
if rest != "" {
|
|
joined = base.JoinPath(rest)
|
|
}
|
|
joined.RawQuery = mergeQueryStrings(joined.RawQuery, requestRawQuery)
|
|
return joined.String(), nil
|
|
}
|
|
|
|
// mergeQueryStrings combines targetQuery (the configured target URL's own
|
|
// query string, returned verbatim when it stands alone) with
|
|
// requestQuery (the incoming client request's own), so neither silently
|
|
// discards the other (F7). A request query that fails to parse is
|
|
// dropped, not treated as a whole-request failure: the request's own
|
|
// method/body may still be perfectly valid, and the target's own query —
|
|
// a credential, per this file's own TargetConfig/AgentConfig doc
|
|
// comments — is the one half of this that must never be lost silently.
|
|
func mergeQueryStrings(targetQuery, requestQuery string) string {
|
|
if targetQuery == "" {
|
|
return requestQuery
|
|
}
|
|
if requestQuery == "" {
|
|
return targetQuery
|
|
}
|
|
merged, err := url.ParseQuery(targetQuery)
|
|
if err != nil {
|
|
// targetQuery came from an already-parsed url.URL's own RawQuery,
|
|
// so this is unreachable in practice — falls back to the
|
|
// request's own query alone rather than panicking or silently
|
|
// dropping both.
|
|
return requestQuery
|
|
}
|
|
reqValues, err := url.ParseQuery(requestQuery)
|
|
if err != nil {
|
|
return targetQuery
|
|
}
|
|
for k, vs := range reqValues {
|
|
for _, v := range vs {
|
|
merged.Add(k, v)
|
|
}
|
|
}
|
|
return merged.Encode()
|
|
}
|
|
|
|
// validateTargetURLs checks every configured MCP-server and agent entry at
|
|
// construction: the map value must be non-nil, the name must pass
|
|
// validateConfigName (the same character-set, dot-only, and reserved-name
|
|
// rules as a provider name — an MCP server or agent is routed at
|
|
// "/mcp/{name}/*" or "/a2a/{name}/*", so the same shadowing risk and the
|
|
// same "." / ".." directory-traversal-segment risk both apply), and the
|
|
// target URL must parse (url.Parse) and use an http or https scheme. A nil
|
|
// value, invalid name, or malformed/non-HTTP target is rejected once, here,
|
|
// at construction — a constructor error, not a nil-pointer panic or a 502
|
|
// the first time some caller happens to address it.
|
|
func validateTargetURLs(cfg *Config) error {
|
|
for name, tc := range cfg.MCPServers {
|
|
if tc == nil {
|
|
return fmt.Errorf("llmgateway: mcpServers %q: config must not be nil", name)
|
|
}
|
|
if err := validateConfigName("mcpServers", name); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTargetURL(tc.URL); err != nil {
|
|
return fmt.Errorf("llmgateway: mcpServers %q: %w", name, err)
|
|
}
|
|
}
|
|
for name, ac := range cfg.Agents {
|
|
if ac == nil {
|
|
return fmt.Errorf("llmgateway: agents %q: config must not be nil", name)
|
|
}
|
|
if err := validateConfigName("agents", name); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTargetURL(ac.URL); err != nil {
|
|
return fmt.Errorf("llmgateway: agents %q: %w", name, err)
|
|
}
|
|
// F8, review round 3, 2026-09: handleAgents builds the card URL as
|
|
// "/a2a/" + name + cardPath (its own doc comment) with no
|
|
// separator of its own — a Card that does not start with "/"
|
|
// (e.g. "agent.json") concatenates straight onto the agent name
|
|
// instead of starting a new path segment, producing something
|
|
// like "/a2a/myagentagent.json": a 404, or a completely different
|
|
// route, never the card the operator configured. Empty (the
|
|
// default, defaultAgentCardPath applies) is always valid.
|
|
if ac.Card != "" && !strings.HasPrefix(ac.Card, "/") {
|
|
return fmt.Errorf("llmgateway: agents %q: card %q must start with \"/\"", name, ac.Card)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateTargetURL reports an error unless raw parses as a URL with an
|
|
// http or https scheme.
|
|
func validateTargetURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid url %q: %w", raw, err)
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("url %q must use http or https scheme, got %q", raw, u.Scheme)
|
|
}
|
|
return nil
|
|
}
|