mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-08 06:16:28 +00:00
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.
192 lines
5.3 KiB
Go
192 lines
5.3 KiB
Go
// Package aiproxy is an authenticated reverse proxy for OpenAI-compatible APIs and
|
|
// MCP servers (streamable HTTP). Upstream URLs and keys stay on the server; clients
|
|
// authenticate with the normal security layer and call the proxy instead.
|
|
//
|
|
// p := aiproxy.New(aiproxy.Config{Prefix: "/ai"})
|
|
// p.RegisterOpenAI(aiproxy.OpenAIUpstream{Upstream: aiproxy.Upstream{Name: "gpt", BaseURL: "https://api.openai.com/v1", APIKey: key}})
|
|
// p.RegisterMCP(aiproxy.MCPUpstream{Upstream: aiproxy.Upstream{Name: "tools", BaseURL: "http://localhost:3000/mcp", APIKey: key}})
|
|
// mux.Handle("/ai/", p.Handler(security.NewAuthMiddleware(securityList)))
|
|
//
|
|
// Client routes: {prefix}/{name}/v1/... (OpenAI) and {prefix}/{name} (MCP).
|
|
package aiproxy
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultMaxBody = 10 << 20
|
|
defaultTimeout = 120 * time.Second
|
|
)
|
|
|
|
// Config configures a Proxy.
|
|
type Config struct {
|
|
// Prefix the handler is mounted under, e.g. "/ai". Empty when mounted with http.StripPrefix.
|
|
Prefix string
|
|
// MaxBodyBytes caps JSON request bodies that are inspected. Default 10 MB.
|
|
MaxBodyBytes int64
|
|
// StripHeaders are removed from client requests in addition to the defaults
|
|
// (Authorization, Cookie, Proxy-Authorization, X-Api-Key).
|
|
StripHeaders []string
|
|
// Audit receives one record per request. Default: LogAuditSink. Use NopAuditSink to disable.
|
|
Audit AuditSink
|
|
}
|
|
|
|
// Definition describes one upstream independent of how it was registered.
|
|
type Definition struct {
|
|
Kind Kind
|
|
Upstream Upstream
|
|
// Allowed holds AllowedModels (OpenAI) or AllowedTools (MCP).
|
|
Allowed []string
|
|
}
|
|
|
|
// target is a registered upstream, ready to serve.
|
|
type target struct {
|
|
def Definition
|
|
kind Kind
|
|
up Upstream
|
|
base *url.URL
|
|
roles map[string]struct{}
|
|
models map[string]struct{}
|
|
tools map[string]struct{}
|
|
proxy *httputil.ReverseProxy
|
|
managed bool // loaded by Reload; code-registered upstreams are never touched by it
|
|
|
|
authName string
|
|
authVal string
|
|
}
|
|
|
|
// Proxy routes authenticated requests to registered upstreams.
|
|
type Proxy struct {
|
|
cfg Config
|
|
hooks *HookRegistry
|
|
limits *limiters
|
|
labels modelLabels
|
|
mu sync.RWMutex
|
|
upstream map[string]*target
|
|
}
|
|
|
|
// New creates a Proxy.
|
|
func New(cfg Config) *Proxy {
|
|
cfg.Prefix = strings.TrimRight(cfg.Prefix, "/")
|
|
if cfg.Audit == nil {
|
|
cfg.Audit = LogAuditSink{}
|
|
}
|
|
if cfg.MaxBodyBytes <= 0 {
|
|
cfg.MaxBodyBytes = defaultMaxBody
|
|
}
|
|
return &Proxy{
|
|
cfg: cfg,
|
|
hooks: NewHookRegistry(),
|
|
limits: newLimiters(),
|
|
upstream: make(map[string]*target),
|
|
}
|
|
}
|
|
|
|
// Hooks returns the hook registry.
|
|
func (p *Proxy) Hooks() *HookRegistry { return p.hooks }
|
|
|
|
// RegisterOpenAI registers an OpenAI-compatible upstream.
|
|
func (p *Proxy) RegisterOpenAI(u OpenAIUpstream) error {
|
|
return p.Register(Definition{Kind: KindOpenAI, Upstream: u.Upstream, Allowed: u.AllowedModels})
|
|
}
|
|
|
|
// RegisterMCP registers an MCP server (streamable HTTP).
|
|
func (p *Proxy) RegisterMCP(u MCPUpstream) error {
|
|
return p.Register(Definition{Kind: KindMCP, Upstream: u.Upstream, Allowed: u.AllowedTools})
|
|
}
|
|
|
|
// Register registers an upstream from a Definition. The name must be unused.
|
|
func (p *Proxy) Register(d Definition) error {
|
|
t, err := p.build(d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if _, dup := p.upstream[t.up.Name]; dup {
|
|
return fmt.Errorf("aiproxy: upstream %q already registered", t.up.Name)
|
|
}
|
|
p.upstream[t.up.Name] = t
|
|
return nil
|
|
}
|
|
|
|
// Unregister removes an upstream (code-registered or loaded). It reports whether it existed.
|
|
// In-flight requests finish normally.
|
|
func (p *Proxy) Unregister(name string) bool {
|
|
p.mu.Lock()
|
|
t, ok := p.upstream[name]
|
|
delete(p.upstream, name)
|
|
p.mu.Unlock()
|
|
if ok {
|
|
p.retire(t)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// retire releases what a removed or replaced target held.
|
|
func (p *Proxy) retire(t *target) {
|
|
p.limits.forget(t.up.Name)
|
|
if tr, ok := t.proxy.Transport.(*http.Transport); ok {
|
|
tr.CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
func (p *Proxy) build(d Definition) (*target, error) {
|
|
if d.Kind != KindOpenAI && d.Kind != KindMCP {
|
|
return nil, fmt.Errorf("aiproxy: upstream %q has unknown kind %q", d.Upstream.Name, d.Kind)
|
|
}
|
|
base, err := d.Upstream.validate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t := &target{def: d, kind: d.Kind, up: d.Upstream, base: base, roles: toSet(d.Upstream.AllowedRoles)}
|
|
if d.Kind == KindOpenAI {
|
|
t.models = toSet(d.Allowed)
|
|
} else {
|
|
t.tools = toSet(d.Allowed)
|
|
}
|
|
t.authName, t.authVal = d.Upstream.authValue()
|
|
t.proxy = p.buildProxy(t)
|
|
return t, nil
|
|
}
|
|
|
|
func (p *Proxy) get(name string) *target {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.upstream[name]
|
|
}
|
|
|
|
// Names lists registered upstream names (no URLs or keys).
|
|
func (p *Proxy) Names() []string {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]string, 0, len(p.upstream))
|
|
for n := range p.upstream {
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newTransport(timeout time.Duration) *http.Transport {
|
|
if timeout <= 0 {
|
|
timeout = defaultTimeout
|
|
}
|
|
return &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConnsPerHost: 32,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: timeout,
|
|
}
|
|
}
|