Files
ResolveSpec/pkg/aiproxy/observe.go
T
warkanum 63cb0d22eb 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.
2026-10-07 23:39:40 +02:00

143 lines
3.8 KiB
Go

package aiproxy
import (
"sync"
"time"
"github.com/bitechdev/ResolveSpec/pkg/logger"
"github.com/bitechdev/ResolveSpec/pkg/metrics"
)
// Outcome classifies a handled request.
type Outcome string
const (
OutcomeOK Outcome = "ok" // upstream answered < 400
OutcomeUpstreamError Outcome = "upstream_error" // upstream answered >= 400 or failed
OutcomeDenied Outcome = "denied" // refused by auth, roles, allowlist, size or a hook
OutcomeRateLimited Outcome = "rate_limited"
)
// AuditRecord is written once per handled request. It holds no bodies and no keys.
type AuditRecord struct {
Time time.Time
UserID int
User string
RemoteID string
Upstream string
Kind Kind
Method string
Path string // client path, no query string
Model string
Tools []string
Status int
Outcome Outcome
Reason string // why a request was refused
Duration time.Duration
Usage Usage
Error string
}
// AuditSink receives audit records. It runs on the request path, so keep it fast
// (queue slow work such as database writes).
type AuditSink interface {
Record(AuditRecord)
}
// LogAuditSink writes records through pkg/logger. It is the default sink.
type LogAuditSink struct{}
// Record implements AuditSink.
func (LogAuditSink) Record(r AuditRecord) {
logger.Info("aiproxy audit: user=%s(%d) upstream=%s kind=%s %s %s model=%q tools=%v status=%d outcome=%s reason=%q duration=%s tokens=%d/%d/%d err=%q",
r.User, r.UserID, r.Upstream, r.Kind, r.Method, r.Path, r.Model, r.Tools, r.Status, r.Outcome, r.Reason, r.Duration,
r.Usage.PromptTokens, r.Usage.CompletionTokens, r.Usage.TotalTokens, r.Error)
}
// NopAuditSink discards records (use it to switch the default logging off).
type NopAuditSink struct{}
// Record implements AuditSink.
func (NopAuditSink) Record(AuditRecord) {}
// maxModelLabels bounds the distinct (upstream, model) label pairs, since the
// model comes from the client.
const maxModelLabels = 400
type modelLabels struct {
mu sync.Mutex
seen map[string]struct{}
}
func (m *modelLabels) label(upstream, model string) string {
if model == "" {
return ""
}
key := upstream + "|" + model
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.seen[key]; ok {
return model
}
if m.seen == nil {
m.seen = make(map[string]struct{})
}
if len(m.seen) >= maxModelLabels {
return "other"
}
m.seen[key] = struct{}{}
return model
}
// record emits the audit record and the metrics for a finished or refused request.
func (p *Proxy) record(t *target, hc *HookContext, status int, outcome Outcome, reason string, err error) {
rec := AuditRecord{
Time: time.Now(),
Upstream: hc.Upstream,
Kind: hc.Kind,
Model: hc.Model,
Tools: hc.Tools,
Status: status,
Outcome: outcome,
Reason: reason,
Duration: hc.Duration,
Usage: hc.Usage,
}
if hc.Request != nil {
rec.Method = hc.Request.Method
rec.Path = hc.Request.URL.Path
}
if u := hc.UserContext; u != nil {
rec.UserID, rec.User, rec.RemoteID = u.UserID, u.UserName, u.RemoteID
}
if err != nil {
rec.Error = err.Error()
}
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("aiproxy: audit sink panic: %v", r)
}
}()
p.cfg.Audit.Record(rec)
}()
if m, ok := metrics.GetProvider().(metrics.AIProxyRecorder); ok && m != nil {
model := ""
if outcome == OutcomeOK || outcome == OutcomeUpstreamError {
model = p.labels.label(hc.Upstream, hc.Model)
}
m.RecordAIProxy(hc.Upstream, string(hc.Kind), model, statusClass(status), string(outcome), hc.Duration,
hc.Usage.PromptTokens, hc.Usage.CompletionTokens)
}
}
// statusClass maps a status code to a low-cardinality label (2xx, 4xx, ...).
func statusClass(status int) string {
if status < 100 || status > 599 {
return "unknown"
}
return string(rune('0'+status/100)) + "xx"
}