Files
homelab-frontend/internal/proxy/router.go
T
rockandAdmin Bot ef5e736673
CI / CI (push) Successful in 3m20s
feat(cag): CAG integration — memory adapter, warmup semaphore, cache-aware routing (#47)
## Summary

Three issues in one PR. Implements Cache-Augmented Generation (CAG) support in the api-gw.

---

### #44 — Memory ServiceAdapter: standing-context resource

- Code-registers `memory` adapter with `standing-context` resource (`POST /memory/standing-context`)
- Fixes dispatcher bug: method-level `Auth.Capability` override now respected (was always using verb-derived capability)
- Auth: `memory:read` required via method-level override; `memory:write` for ingest
- `MEMORY_UPSTREAM_URL` env configures upstream

### #45 — Continuous batching: warmup deprioritisation + per-backend semaphore

- In-memory bounded queue per backend: `VLLM_MAX_QUEUE_DEPTH` slots (default 20)
- Concurrency limiter: `VLLM_MAX_NUM_SEQS` slots (default 4)
- `X-Poimen-Warmup: true` → sleep `WARMUP_DELAY_MS` (default 500ms) before queue entry, strip header, add `X-Priority: low`
- Queue full → 503 immediately, no goroutine pile-up
- Metrics: `warmup_requests_delayed_total`, `queue_full_requests_total` at `/metrics`

### #46 — Cache-aware routing: rendezvous hash + prefix cache hit logging

- `addresses: [...]` YAML field for multi-backend models (backward-compat with `address`)
- `X-Poimen-Cache-Key` header → rendezvous-hash backend selection (same key = same GPU)
- No cache key → round-robin
- Failed backend excluded 30s; next-highest-score fallback
- `vllm-num-cached-tokens` response header captured → `vllm_prefix_cache_hit_tokens_total` counter + structured log

---

## Verification

```
go build ./...    ✅
go vet ./...      ✅
go test -race ./... ✅ (all packages, 0 races)
```

## Reviewer verdicts

- reviewer-sre: SIGN-OFF
- reviewer-quality: SIGN-OFF

## Non-blocking findings (🟡🔵 only)

- `getOrCreateTransport` not goroutine-safe by design; pre-population invariant documented
- `time.Sleep(warmupDelay)` holds goroutine; context-cancel alternative left as TODO
- OTel trace_id not propagated into CAG log events (existing gap)

Closes #44 Closes #45 Closes #46

---------

Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: #47
2026-09-27 11:16:40 +00:00

172 lines
4.9 KiB
Go

// Package proxy provides request routing and forwarding.
package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
)
// RouteRequest determines which upstream should handle the request.
// For /v1/chat/completions, /v1/embeddings, and /v1/rerank, it uses body-based dispatch.
// For other routes, it looks up by path in the configured routes.
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
// For /v1/chat/completions, /v1/embeddings, /v1/rerank use body-based dispatch
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank") {
return h.routeByModel(r, r.URL.Path)
}
// 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.
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank)
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
// If there's no body, we can't determine the model
if r.Body == nil {
return nil, &modelValidationError{
Kind: "missing_model",
Message: "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, &modelValidationError{
Kind: "invalid_request",
Message: fmt.Sprintf("failed to read request body: %v", 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, &modelValidationError{
Kind: "invalid_json",
Message: "request body is not valid JSON",
}
}
// Extract the model name
modelVal, hasModel := payload["model"]
if !hasModel {
return nil, &modelValidationError{
Kind: "missing_model",
Message: "'model' field is missing",
}
}
// Handle null model
if modelVal == nil {
return nil, &modelValidationError{
Kind: "null_model",
Message: "'model' field is null",
}
}
// Extract as string
modelName, ok := modelVal.(string)
if !ok {
return nil, &modelValidationError{
Kind: "missing_model",
Message: "'model' field must be a string",
}
}
// Handle empty string
if modelName == "" {
return nil, &modelValidationError{
Kind: "empty_model",
Message: "'model' field cannot be empty",
}
}
// Look up the model in the registry
modelUpstream := h.config.LookupModel(modelName)
if modelUpstream == nil {
return nil, &modelValidationError{
Kind: "unknown_model",
Message: fmt.Sprintf("unknown model: %q", modelName),
Model: modelName,
}
}
// If upstream expects a different model name, rewrite the body
if modelUpstream.UpstreamModel != "" && modelUpstream.UpstreamModel != modelName {
payload["model"] = modelUpstream.UpstreamModel
newBody, err := json.Marshal(payload)
if err != nil {
return nil, &modelValidationError{
Kind: "invalid_request",
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
}
}
r.Body = io.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
}
// Determine the upstream path based on the request path
upstreamPath := path
if path == "/v1/rerank" {
// Rerank endpoint uses /rerank path on upstream
upstreamPath = "/rerank"
}
// Create a route for this model with appropriate timeouts
// These are sensible defaults for LLM models
upstreamCfg := &config.Upstream{
Address: modelUpstream.Address,
PathRewrite: upstreamPath,
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,
ModelName: modelName,
Upstream: upstreamCfg,
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
Director: func(req *http.Request) {
directorFunc(req, targetURL, upstreamCfg)
},
}
return route, nil
}