Files
homelab-frontend/internal/proxy/router.go
T
Story Crater Bot b0ce2fb67c feat: build and publish the gateway image via Forgejo Actions
- Dockerfile: multi-stage, distroless nonroot, CGO_ENABLED=0 static, commit
  SHA stamped via VERSION build arg.
- .forgejo/workflows/ci.yaml: Forgejo reads .forgejo/, not .github/, and the
  runner declares only the "docker" label. Verify job on every push; image
  build and push gated to main.
- Drop .github/workflows/ci.yml — this remote is Forgejo, so it never ran.
- deployment.yaml: image from the Forgejo registry, forgejo-registry pull
  secret, runAsUser 65532 to match distroless nonroot.
- kustomization.yaml: pin the tag in one place. Promoting a build is a
  one-line newTag bump, never :latest.
2026-08-19 21:48:11 -07:00

107 lines
3.1 KiB
Go

// Package proxy provides request routing and forwarding.
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"github.com/Riotpiaole/homelab-frontend/internal/config"
)
// RouteRequest determines which upstream should handle the request.
// For /v1/chat/completions, it uses body-based dispatch (reads JSON to find "model" field).
// For other routes, it looks up by path prefix.
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
// For /v1/chat/completions, use body-based dispatch
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
return h.routeByModel(r)
}
// For other paths, try to find a matching route by path
// Look for exact path match or path prefix match
for _, route := range h.routes {
// Check if route's pathRewrite matches request path
if route.Upstream.PathRewrite != "" && route.Upstream.PathRewrite == r.URL.Path {
return route, nil
}
}
// If no routes configured, return error
if len(h.routes) == 0 {
return nil, fmt.Errorf("no routes configured")
}
// Return the first configured route as fallback
for _, route := range h.routes {
return route, nil
}
return nil, fmt.Errorf("no route available")
}
// routeByModel reads the request body to find the "model" field and routes accordingly.
// The body is preserved for forwarding to the upstream.
func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
// If there's no body, we can't determine the model
if r.Body == nil {
return nil, fmt.Errorf("request body required")
}
// Read the body to extract the model name
// We need to be careful to preserve the body for the upstream
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
// Restore the body so it can be read again by the upstream
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Parse the JSON to find the model field
var payload map[string]interface{}
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
return nil, fmt.Errorf("invalid JSON in request body: %w", err)
}
// Extract the model name
modelName, ok := payload["model"].(string)
if !ok {
return nil, fmt.Errorf("model field missing or not a string")
}
// Look up the model in the registry
modelUpstream := h.config.LookupModel(modelName)
if modelUpstream == nil {
return nil, fmt.Errorf("unknown model: %q", modelName)
}
// Create a route for this model with appropriate timeouts
// These are sensible defaults for LLM models
upstreamCfg := &config.Upstream{
Address: modelUpstream.Address,
PathRewrite: "/v1/chat/completions",
ConnectTimeout: h.defaultConnectTimeout,
ReadTimeout: h.defaultReadTimeout,
WriteTimeout: h.defaultWriteTimeout,
MaxBodySize: h.defaultMaxBodySize,
AuthRequired: false,
}
targetURL, _ := url.Parse("http://" + modelUpstream.Address)
route := &Route{
Name: "v1-chat-" + modelName,
Upstream: upstreamCfg,
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
Director: func(req *http.Request) {
directorFunc(req, targetURL, upstreamCfg)
},
}
return route, nil
}