feat(cag): memory standing-context adapter, warmup semaphore, cache-aware routing
Issue #44: code-register memory adapter with standing-context resource; fix dispatcher method-level Auth.Capability override (was always using verb-derived capability, ignoring per-method Auth field). Issue #45: per-backend bounded in-memory queue (VLLM_MAX_QUEUE_DEPTH=20) + concurrency semaphore (VLLM_MAX_NUM_SEQS=4); warmup requests (X-Poimen-Warmup: true) sleep WARMUP_DELAY_MS before queue entry; /metrics endpoint wired. Issue #46: rendezvous-hash backend selection on X-Poimen-Cache-Key; round-robin fallback; MarkFailed excludes unhealthy backends 30s; ModifyResponse captures vllm-num-cached-tokens + emits structured log + increments counter. Closes #44 Closes #45 Closes #46
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package serviceadapter
|
||||
|
||||
import "os"
|
||||
|
||||
// NewMemoryAdapter creates a code-registered ServiceAdapter for the memory service.
|
||||
// Takes precedence over any config-file loaded memory adapter (main.go skips config
|
||||
// adapters when a code-registered one with the same name exists).
|
||||
// Upstream URL configured via MEMORY_UPSTREAM_URL env var.
|
||||
func NewMemoryAdapter() *ServiceAdapter {
|
||||
upstreamURL := os.Getenv("MEMORY_UPSTREAM_URL")
|
||||
if upstreamURL == "" {
|
||||
upstreamURL = "http://poimen-memory.poimen.svc.cluster.local:8080"
|
||||
}
|
||||
return &ServiceAdapter{
|
||||
Namespace: "poimen",
|
||||
ServiceName: "memory",
|
||||
Spec: *GetMemorySpec(upstreamURL),
|
||||
}
|
||||
}
|
||||
|
||||
// GetMemorySpec returns the ServiceAdapter spec for the memory service.
|
||||
func GetMemorySpec(upstreamURL string) *Spec {
|
||||
return &Spec{
|
||||
ServiceName: "memory",
|
||||
Upstream: Upstream{
|
||||
URL: upstreamURL,
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{
|
||||
Required: true,
|
||||
Capability: "memory:read",
|
||||
},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "ingest",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/ingest",
|
||||
Auth: &Auth{Required: true, Capability: "memory:write"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "query",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/query",
|
||||
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "skills",
|
||||
Methods: []Method{{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/memory/skills",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "standing-context",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/standing-context",
|
||||
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// JWT auth enforcement for adapters that require it
|
||||
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
|
||||
if !d.authenticate(w, r, serviceName, method.Verb) {
|
||||
if !d.authenticate(w, r, serviceName, method) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// authenticate validates the JWT and checks service-level capability.
|
||||
// Returns false (and writes error response) if auth fails.
|
||||
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
|
||||
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName string, method *Method) bool {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
||||
@@ -115,8 +115,11 @@ func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, servic
|
||||
return false
|
||||
}
|
||||
|
||||
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
|
||||
required := capabilityForVerb(serviceName, verb)
|
||||
// Check capability: method-level Auth.Capability if set; else derive from verb.
|
||||
required := capabilityForVerb(serviceName, method.Verb)
|
||||
if method.Auth != nil && method.Auth.Capability != "" {
|
||||
required = method.Auth.Capability
|
||||
}
|
||||
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
||||
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
||||
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
||||
|
||||
@@ -263,3 +263,59 @@ func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
|
||||
t.Errorf("X-Auth-Verified should not be set without auth, got %q", gotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatch_MethodCapabilityOverridesPOSTDefault verifies that a method-level
|
||||
// Auth.Capability (e.g. "memory:read" on a POST standing-context endpoint) is
|
||||
// respected instead of the verb-derived capability ("memory:write").
|
||||
// Because auth.Validator is a concrete struct requiring real JWKS, we pass nil
|
||||
// jwtValidator so auth is bypassed and assert only that routing succeeds (not 403).
|
||||
func TestDispatch_MethodCapabilityOverridesPOSTDefault(t *testing.T) {
|
||||
// Upstream captures the request so we can verify it was routed.
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
// Memory-like adapter: POST standing-context with method-level Auth.Capability=memory:read.
|
||||
adapter := ServiceAdapter{
|
||||
Name: "memory",
|
||||
ServiceName: "memory",
|
||||
Spec: Spec{
|
||||
ServiceName: "memory",
|
||||
Upstream: Upstream{URL: upstream.URL, TimeoutSeconds: 5},
|
||||
Auth: Auth{Required: false},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "standing-context",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/standing-context",
|
||||
// Override: read capability even though verb is POST.
|
||||
Auth: &Auth{Capability: "memory:read"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// nil jwtValidator: auth.Required=false + nil validator → auth skipped entirely.
|
||||
d := NewDispatcher(newTestRegistry(adapter), nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.Header.Set("X-Service", "memory")
|
||||
r.Header.Set("X-Resource", "standing-context")
|
||||
|
||||
d.Dispatch(w, r)
|
||||
|
||||
// Must NOT be 403: the method-level memory:read override is not checked
|
||||
// against verb-derived memory:write (auth is bypassed with nil validator).
|
||||
// Must be 200 routed to real upstream.
|
||||
if w.Code == http.StatusForbidden {
|
||||
t.Errorf("got 403 Forbidden; memory:read override should not produce forbidden (auth bypassed)")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 from upstream, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user