feat(server): add passthrough proxy for OpenAI-compatible endpoints

* implement proxy handler for various OpenAI API routes
* add error handling for request body and response streaming
* introduce new error response format for API compatibility
* add tests for recover middleware to handle panics gracefully
This commit is contained in:
2026-08-01 20:15:59 +02:00
parent 52902a8383
commit 1953a4f4f9
11 changed files with 1087 additions and 41 deletions
+99 -16
View File
@@ -13,6 +13,7 @@ import (
"github.com/Warky-Devs/vecna.git/pkg/adapter"
"github.com/Warky-Devs/vecna.git/pkg/config"
"github.com/Warky-Devs/vecna.git/pkg/embedclient"
"github.com/Warky-Devs/vecna.git/pkg/forwardproxy"
"github.com/Warky-Devs/vecna.git/pkg/metrics"
"github.com/Warky-Devs/vecna.git/pkg/server/spec"
)
@@ -24,6 +25,7 @@ func New(
clients map[string]embedclient.Client,
adp adapter.Adapter,
extraMaps map[string]ExtraMap,
forwarders map[string]*forwardproxy.Router,
reg *metrics.Registry,
logger *zap.Logger,
) (router *bunrouter.Router, err error) {
@@ -35,12 +37,13 @@ func New(
}()
router = bunrouter.New(
bunrouter.WithMiddleware(recoverMiddleware(logger)),
bunrouter.WithMiddleware(traceMiddleware()),
bunrouter.WithMiddleware(metricsMiddleware(reg, adp)),
bunrouter.WithMiddleware(loggingMiddleware(logger)),
)
h := &handler{cfg: cfg, clients: clients, adapter: adp, extraMaps: extraMaps, logger: logger}
h := &handler{cfg: cfg, clients: clients, adapter: adp, extraMaps: extraMaps, forwarders: forwarders, logger: logger}
// Public routes — no authentication required.
router.GET("/", spec.DocsHandler())
@@ -51,6 +54,7 @@ func New(
authed := router.NewGroup("", bunrouter.WithMiddleware(authMiddleware(cfg.Server.APIKeys)))
authed.POST("/v1/embeddings", h.openAIEmbeddings)
authed.GET("/v1/embeddings", h.openAIEmbeddingsGet)
// Google API uses a literal colon as a method separator (e.g. /v1/models/foo:embedContent).
// bunrouter can't distinguish two routes with the same :param prefix, so a single wildcard
// captures the full "model:action" segment and dispatches internally.
@@ -58,8 +62,15 @@ func New(
// Extra-map routes: /map/:mapping/v1/... uses the adapter configured under extra_maps[mapping].
authed.POST("/map/:mapping/v1/embeddings", h.openAIEmbeddingsMapped)
authed.GET("/map/:mapping/v1/embeddings", h.openAIEmbeddingsGetMapped)
authed.POST("/map/:mapping/v1/models/*modelaction", h.googleDispatchMapped)
// Generic OpenAI-compatible passthrough — forwarded verbatim to the resolved
// target with no vecna-specific processing (no dimension adaptation). The
// target is chosen the same way as embeddings: the body's "model" field must
// name a configured forward target, falling back to forward.default.
registerPassthroughRoutes(authed, h)
// Metrics — only when enabled.
// If metrics.api_key is set, routes are guarded by that key (on the authed group).
// If metrics.api_key is blank, routes are public — no auth headers checked at all.
@@ -85,7 +96,65 @@ func New(
return router, nil
}
// authMiddleware rejects requests without a valid Bearer token when api_keys is configured.
// recoverMiddleware catches panics from any handler or middleware below it.
// Without this, an unrecovered panic kills the request goroutine mid-response
// and the client sees a raw connection reset instead of an error; with it,
// the client gets a normal 500 JSON error (or, if the panic happened after
// the response was already committed — e.g. mid-stream — the connection is
// still cut, but the panic is at least logged instead of silently crashing).
func recoverMiddleware(logger *zap.Logger) bunrouter.MiddlewareFunc {
return func(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) (err error) {
rw := &recoverWriter{ResponseWriter: w}
defer func() {
r := recover()
if r == nil {
return
}
logger.Error("panic recovered",
zap.Any("recover", r),
zap.String("method", req.Method),
zap.String("path", req.URL.Path),
zap.Stack("stack"),
)
if rw.wroteHeader {
err = fmt.Errorf("panic after response started: %v", r)
return
}
err = writeError(rw, http.StatusInternalServerError, "internal server error")
}()
return next(rw, req)
}
}
}
// recoverWriter tracks whether a response has already been committed, so
// recoverMiddleware knows whether it's still safe to write an error body.
type recoverWriter struct {
http.ResponseWriter
wroteHeader bool
}
func (rw *recoverWriter) WriteHeader(code int) {
rw.wroteHeader = true
rw.ResponseWriter.WriteHeader(code)
}
func (rw *recoverWriter) Write(b []byte) (int, error) {
rw.wroteHeader = true
return rw.ResponseWriter.Write(b)
}
func (rw *recoverWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// authMiddleware rejects requests without a valid API key when api_keys is
// configured. The key may be supplied as a Bearer token (OpenAI-style), or
// via the X-Api-Key or Api-Key headers (Anthropic/Azure OpenAI-style), so
// clients built against any of those conventions work unmodified.
func authMiddleware(apiKeys []string) bunrouter.MiddlewareFunc {
if len(apiKeys) == 0 {
return func(next bunrouter.HandlerFunc) bunrouter.HandlerFunc { return next }
@@ -96,16 +165,27 @@ func authMiddleware(apiKeys []string) bunrouter.MiddlewareFunc {
}
return func(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) error {
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
if _, ok := keySet[token]; !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil
token := requestAPIKey(req.Request)
if _, ok := keySet[token]; !ok || token == "" {
return writeError(w, http.StatusUnauthorized, "unauthorized")
}
return next(w, req)
}
}
}
// requestAPIKey extracts the client-supplied API key, checking (in order)
// the Authorization Bearer scheme, X-Api-Key, and Api-Key headers.
func requestAPIKey(req *http.Request) string {
if auth := req.Header.Get("Authorization"); auth != "" {
return strings.TrimPrefix(auth, "Bearer ")
}
if key := req.Header.Get("X-Api-Key"); key != "" {
return key
}
return req.Header.Get("Api-Key")
}
// traceMiddleware injects a *RequestTrace into every request context.
func traceMiddleware() bunrouter.MiddlewareFunc {
return func(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {
@@ -161,25 +241,22 @@ func loggingMiddleware(logger *zap.Logger) bunrouter.MiddlewareFunc {
}
}
// metricsKeyMiddleware guards a bunrouter.HandlerFunc with a dedicated Bearer token.
// metricsKeyMiddleware guards a bunrouter.HandlerFunc with a dedicated API key
// (Bearer, X-Api-Key, or Api-Key — see requestAPIKey).
func metricsKeyMiddleware(apiKey string, h bunrouter.HandlerFunc) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) error {
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
if token != apiKey {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil
if requestAPIKey(req.Request) != apiKey {
return writeError(w, http.StatusUnauthorized, "unauthorized")
}
return h(w, req)
}
}
// metricsAuthHandler wraps a standard http.Handler with Bearer token auth.
// metricsAuthHandler wraps a standard http.Handler with the same API key auth.
func metricsAuthHandler(apiKey string, h http.Handler) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) error {
token := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
if token != apiKey {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil
if requestAPIKey(req.Request) != apiKey {
return writeError(w, http.StatusUnauthorized, "unauthorized")
}
h.ServeHTTP(w, req.Request)
return nil
@@ -196,3 +273,9 @@ func (sw *statusWriter) WriteHeader(code int) {
sw.status = code
sw.ResponseWriter.WriteHeader(code)
}
func (sw *statusWriter) Flush() {
if f, ok := sw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}