feat(aiproxy): add authenticated proxy for OpenAI-compatible APIs and MCP servers

Hides upstream URLs and keys behind the security layer. Per-upstream roles,
model/tool allowlists, per-user rate limits, Before/AfterProxy hooks, audit
sink, Prometheus metrics (metrics.AIProxyRecorder) and upstreams loaded from
the resolvespec_ai_proxies stored procedure.
This commit is contained in:
2026-10-07 23:39:40 +02:00
parent e4c4315f4b
commit 63cb0d22eb
14 changed files with 2088 additions and 0 deletions
+8
View File
@@ -47,6 +47,14 @@ type Provider interface {
Handler() http.Handler
}
// AIProxyRecorder is optionally implemented by providers that record aiproxy traffic.
// pkg/aiproxy uses it when the global provider implements it.
type AIProxyRecorder interface {
// RecordAIProxy records one proxied (or refused) request. statusClass is "2xx".."5xx",
// outcome is ok, upstream_error, denied or rate_limited, model may be empty.
RecordAIProxy(upstream, kind, model, statusClass, outcome string, duration time.Duration, promptTokens, completionTokens int64)
}
// Resetter is optionally implemented by providers that can clear their recorded stats.
type Resetter interface {
Reset()
+43
View File
@@ -32,6 +32,9 @@ type PrometheusProvider struct {
eventDuration *prometheus.HistogramVec
eventQueueSize prometheus.Gauge
panicsTotal *prometheus.CounterVec
aiRequests *prometheus.CounterVec
aiDuration *prometheus.HistogramVec
aiTokens *prometheus.CounterVec
pathLimiter *pathLimiter
pathNormalizer func(*http.Request) string
@@ -162,6 +165,28 @@ func NewPrometheusProvider(cfg *Config) *PrometheusProvider {
},
[]string{"method"},
),
aiRequests: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: metricName("aiproxy_requests_total"),
Help: "Total number of requests handled by the AI proxy",
},
[]string{"upstream", "kind", "model", "status", "outcome"},
),
aiDuration: promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: metricName("aiproxy_request_duration_seconds"),
Help: "AI proxy request duration in seconds",
Buckets: cfg.HTTPRequestBuckets,
},
[]string{"upstream", "kind"},
),
aiTokens: promauto.NewCounterVec(
prometheus.CounterOpts{
Name: metricName("aiproxy_tokens_total"),
Help: "Tokens reported by AI proxy upstreams",
},
[]string{"upstream", "model", "type"},
),
pathLimiter: newPathLimiter(cfg.HTTPMaxPaths),
pathNormalizer: cfg.HTTPPathNormalizer,
@@ -310,6 +335,21 @@ func (p *PrometheusProvider) RecordPanic(methodName string) {
p.panicsTotal.WithLabelValues(methodName).Inc()
}
// RecordAIProxy implements AIProxyRecorder
func (p *PrometheusProvider) RecordAIProxy(upstream, kind, model, statusClass, outcome string, duration time.Duration, promptTokens, completionTokens int64) {
if !p.enabled {
return
}
p.aiRequests.WithLabelValues(upstream, kind, model, statusClass, outcome).Inc()
p.aiDuration.WithLabelValues(upstream, kind).Observe(duration.Seconds())
if promptTokens > 0 {
p.aiTokens.WithLabelValues(upstream, model, "prompt").Add(float64(promptTokens))
}
if completionTokens > 0 {
p.aiTokens.WithLabelValues(upstream, model, "completion").Add(float64(completionTokens))
}
}
// Handler implements Provider interface
// It responds 404 when metrics are disabled.
func (p *PrometheusProvider) Handler() http.Handler {
@@ -437,6 +477,9 @@ func (p *PrometheusProvider) Reset() {
p.eventProcessed.Reset()
p.eventDuration.Reset()
p.panicsTotal.Reset()
p.aiRequests.Reset()
p.aiDuration.Reset()
p.aiTokens.Reset()
p.pathLimiter.reset()
}