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
+73 -5
View File
@@ -12,6 +12,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"
)
// ExtraMap pairs a dimension adapter with an optional forward-target override.
@@ -22,11 +23,12 @@ type ExtraMap struct {
// handler holds shared dependencies for all HTTP handlers.
type handler struct {
cfg *config.Config
clients map[string]embedclient.Client
adapter adapter.Adapter
extraMaps map[string]ExtraMap
logger *zap.Logger
cfg *config.Config
clients map[string]embedclient.Client
adapter adapter.Adapter
extraMaps map[string]ExtraMap
forwarders map[string]*forwardproxy.Router
logger *zap.Logger
}
// resolveExtraMap returns the ExtraMap for the named extra_map entry.
@@ -66,6 +68,33 @@ func (h *handler) resolveClientOverride(targetOverride, model string) (embedclie
return c, targetOverride, firstEndpointURL(h.cfg, targetOverride)
}
// resolveForwarder selects the raw forwarder for the given model, falling back
// to forward.default when the model doesn't name a configured target directly.
func (h *handler) resolveForwarder(model string) (*forwardproxy.Router, string, error) {
if f, ok := h.forwarders[model]; ok {
return f, model, nil
}
name := h.cfg.Forward.Default
f, ok := h.forwarders[name]
if !ok {
return nil, name, fmt.Errorf("no forward target configured for model %q and no default", model)
}
return f, name, nil
}
// resolveForwarderOverride selects the forwarder for targetOverride when set,
// otherwise falls back to model-based resolution.
func (h *handler) resolveForwarderOverride(targetOverride, model string) (*forwardproxy.Router, string, error) {
if targetOverride == "" {
return h.resolveForwarder(model)
}
f, ok := h.forwarders[targetOverride]
if !ok {
return nil, targetOverride, fmt.Errorf("extra_map forward_target %q not configured", targetOverride)
}
return f, targetOverride, nil
}
func firstEndpointURL(cfg *config.Config, targetName string) string {
t, ok := cfg.Forward.Targets[targetName]
if !ok || len(t.Endpoints) == 0 {
@@ -84,6 +113,45 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) error {
return nil
}
// apiError is the OpenAI-compatible error envelope, so existing OpenAI SDKs
// (which read err.error.message) surface vecna's own errors correctly instead
// of failing to parse a flat {"error": "..."} string.
type apiError struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
}
type apiErrorEnvelope struct {
Error apiError `json:"error"`
}
// writeError writes an OpenAI-compatible error response.
func writeError(w http.ResponseWriter, status int, message string) error {
return writeJSON(w, status, apiErrorEnvelope{Error: apiError{
Message: message,
Type: errorType(status),
Code: status,
}})
}
// errorType maps an HTTP status to an OpenAI-style error type string.
func errorType(status int) string {
switch status {
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusBadRequest, http.StatusNotFound:
return "invalid_request_error"
case http.StatusBadGateway:
return "upstream_error"
default:
if status >= 500 {
return "internal_error"
}
return "api_error"
}
}
// writeTraceHeaders writes X-Vecna-* timing headers from the RequestTrace.
func writeTraceHeaders(w http.ResponseWriter, t *RequestTrace) {
total := time.Since(t.Start)