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
+182
View File
@@ -0,0 +1,182 @@
package server
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/uptrace/bunrouter"
"github.com/Warky-Devs/vecna.git/pkg/forwardproxy"
)
// passthroughRoute describes one OpenAI-compatible endpoint forwarded verbatim.
type passthroughRoute struct {
method string
path string // route pattern registered with bunrouter (":model" etc.)
}
// passthroughRoutes lists the OpenAI-compatible endpoints vecna forwards without
// any body processing, beyond /v1/embeddings (which gets dimension adaptation).
var passthroughRoutes = []passthroughRoute{
{http.MethodGet, "/v1/models"},
{http.MethodGet, "/v1/models/:model"},
{http.MethodPost, "/v1/chat/completions"},
{http.MethodPost, "/v1/completions"},
{http.MethodPost, "/v1/moderations"},
{http.MethodPost, "/v1/images/generations"},
{http.MethodPost, "/v1/images/edits"},
{http.MethodPost, "/v1/images/variations"},
{http.MethodPost, "/v1/audio/speech"},
{http.MethodPost, "/v1/audio/transcriptions"},
{http.MethodPost, "/v1/audio/translations"},
{http.MethodPost, "/v1/rerank"},
}
// registerPassthroughRoutes wires up both the unmapped and /map/:mapping variants
// of every entry in passthroughRoutes.
func registerPassthroughRoutes(authed *bunrouter.Group, h *handler) {
for _, r := range passthroughRoutes {
upstreamPath := strings.Replace(r.path, ":model", "{model}", 1)
authed.Handle(r.method, r.path, h.proxyHandler(r.method, upstreamPath))
authed.Handle(r.method, "/map/:mapping"+r.path, h.proxyHandlerMapped(r.method, upstreamPath))
}
}
// proxyHandler returns a handler that forwards method/path verbatim to the
// target resolved from the request body's "model" field (or forward.default).
// No vecna-specific processing (adaptation, etc.) is applied to the body.
func (h *handler) proxyHandler(method, path string) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) error {
return h.doProxy(w, req, method, path, "")
}
}
// proxyHandlerMapped is the /map/:mapping variant: the target is always the
// extra_map's forward_target, regardless of the body's "model" field.
func (h *handler) proxyHandlerMapped(method, path string) bunrouter.HandlerFunc {
return func(w http.ResponseWriter, req bunrouter.Request) error {
em, err := h.resolveExtraMap(req.Param("mapping"))
if err != nil {
return writeError(w, http.StatusNotFound, err.Error())
}
return h.doProxy(w, req, method, path, em.ForwardTarget)
}
}
func (h *handler) doProxy(w http.ResponseWriter, req bunrouter.Request, method, pathTemplate, targetOverride string) error {
model := req.Param("model")
upstreamPath := strings.Replace(pathTemplate, "{model}", model, 1)
var body []byte
if method != http.MethodGet && method != http.MethodDelete {
b, err := io.ReadAll(req.Body)
if err != nil {
return writeError(w, http.StatusBadRequest, "invalid request body")
}
body = b
if model == "" && strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") {
model = modelPeek(body)
}
}
fwd, targetName, err := h.resolveForwarderOverride(targetOverride, model)
if err != nil {
return writeError(w, http.StatusBadGateway, err.Error())
}
trace := TraceFromContext(req.Context())
trace.ForwardTarget = targetName
if isStreaming(body) {
return h.streamProxy(w, req, fwd, method, upstreamPath, body, trace)
}
t0 := time.Now()
res, err := fwd.Do(req.Context(), method, upstreamPath, body, req.Header)
trace.ForwardDuration = time.Since(t0)
if err != nil {
return writeError(w, http.StatusBadGateway, err.Error())
}
trace.ForwardURL = res.Target
writeTraceHeaders(w, trace)
writeUpstreamResult(w, res)
return nil
}
// streamProxy relays a single-attempt streaming response (e.g. SSE chat
// completions) directly to the client as bytes arrive.
func (h *handler) streamProxy(w http.ResponseWriter, req bunrouter.Request, fwd *forwardproxy.Router, method, path string, body []byte, trace *RequestTrace) error {
t0 := time.Now()
status, header, respBody, err := fwd.DoStream(req.Context(), method, path, body, req.Header)
trace.ForwardDuration = time.Since(t0)
if err != nil {
return writeError(w, http.StatusBadGateway, err.Error())
}
defer func() { _ = respBody.Close() }()
dst := w.Header()
for k, vv := range header {
if forwardproxy.SkipResponseHeader(k) {
continue
}
for _, v := range vv {
dst.Add(k, v)
}
}
w.WriteHeader(status)
flusher, _ := w.(http.Flusher)
buf := make([]byte, 4096)
for {
n, rerr := respBody.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return nil
}
if flusher != nil {
flusher.Flush()
}
}
if rerr != nil {
break
}
}
return nil
}
func writeUpstreamResult(w http.ResponseWriter, res forwardproxy.Result) {
dst := w.Header()
for k, vv := range res.Header {
if forwardproxy.SkipResponseHeader(k) {
continue
}
for _, v := range vv {
dst.Add(k, v)
}
}
w.WriteHeader(res.Status)
_, _ = w.Write(res.Body)
}
// modelPeek extracts the "model" field from a JSON body without full decoding,
// used to select a forward target the same way embeddings does.
func modelPeek(body []byte) string {
var v struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &v)
return v.Model
}
// isStreaming reports whether a JSON request body sets "stream": true.
func isStreaming(body []byte) bool {
var v struct {
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &v)
return v.Stream
}