feat(proxy): engine-wide LLM admission control via vLLM gauges (#64)
CI / CI (pull_request) Successful in 34m5s
CI / CI (pull_request) Successful in 34m5s
The per-pod semaphore was not a cap: 3 replicas x 4 slots advertised 12 concurrent requests while the engine runs 4, and nothing coordinated the replicas. Admission is now gated on the engine's own published gauges, which is global by construction because every replica contends for the same engine. - new internal/proxy/engine_state.go: 2s scrape of the backend /metrics, hand-rolled exposition parse (no new dependency), unhealthy after 3 consecutive failed polls, and a startup self-check that raises observer_degraded rather than falling back to fail-open silently - gate: park while kv_cache_usage_perc >= 0.90 or running + waiting >= ADMISSION_MAX_INFLIGHT, bounded by ADMISSION_MAX_PARK_SECONDS, then 503 + Retry-After: 5. Unknown state fails open on purpose - streaming requests receive SSE keep-alive heartbeats while parked - router: extract only the top-level model field instead of unmarshalling the whole body into a map; body cap from MAX_BODY_BYTES (1 MiB, the model path previously inherited the 100 MB default) - GOMAXPROCS from the cgroup CPU quota: the container is limited to 500m while Go defaulted to the host core count and was CFS-throttled - VLLM_MAX_QUEUE_DEPTH default 20 -> 8 - delete k8s/configmap.yaml: absent from kustomization.yaml, and the live ConfigMap of that name carries an unrelated key - correct the llm-client NetworkPolicy comment; llm-serving-ingress now selects by namespace, not by that label Observers run only for models named in ADMISSION_OBSERVE_MODELS. reasoning is the only vLLM engine here; ornith and qwen-cpu are Ollama and embeddings/rerank are TEI, none of which publish vllm:* series, so observing them would hold observer_degraded at 1 permanently. Companion change (engine flags + dashboard panels) is riotpiao-poimen/homelab#51.
This commit is contained in:
+112
-12
@@ -69,9 +69,10 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
// 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 {
|
||||
// Extract only the top-level "model" value with a token walk: the common
|
||||
// path must not materialise the whole body (messages and all) as a map.
|
||||
modelRaw, hasModel, err := extractTopLevelModel(bodyBytes)
|
||||
if err != nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_json",
|
||||
Message: "request body is not valid JSON",
|
||||
@@ -79,7 +80,6 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
}
|
||||
|
||||
// Extract the model name
|
||||
modelVal, hasModel := payload["model"]
|
||||
if !hasModel {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
@@ -88,7 +88,7 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
}
|
||||
|
||||
// Handle null model
|
||||
if modelVal == nil {
|
||||
if bytes.Equal(bytes.TrimSpace(modelRaw), []byte("null")) {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "null_model",
|
||||
Message: "'model' field is null",
|
||||
@@ -96,8 +96,8 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
}
|
||||
|
||||
// Extract as string
|
||||
modelName, ok := modelVal.(string)
|
||||
if !ok {
|
||||
var modelName string
|
||||
if err := json.Unmarshal(modelRaw, &modelName); err != nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "'model' field must be a string",
|
||||
@@ -122,10 +122,26 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// If upstream expects a different model name, rewrite the body
|
||||
// If upstream expects a different model name, rewrite the body. This path is
|
||||
// uncommon, so it may decode the body as raw fields; the common path above
|
||||
// does not build a full map.
|
||||
if modelUpstream.UpstreamModel != "" && modelUpstream.UpstreamModel != modelName {
|
||||
payload["model"] = modelUpstream.UpstreamModel
|
||||
newBody, err := json.Marshal(payload)
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(bodyBytes, &fields); err != nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_request",
|
||||
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
|
||||
}
|
||||
}
|
||||
newModel, err := json.Marshal(modelUpstream.UpstreamModel)
|
||||
if err != nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_request",
|
||||
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
|
||||
}
|
||||
}
|
||||
fields["model"] = newModel
|
||||
newBody, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_request",
|
||||
@@ -151,11 +167,11 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
ConnectTimeout: h.defaultConnectTimeout,
|
||||
ReadTimeout: h.defaultReadTimeout,
|
||||
WriteTimeout: h.defaultWriteTimeout,
|
||||
MaxBodySize: h.defaultMaxBodySize,
|
||||
MaxBodySize: h.maxBodyBytes,
|
||||
AuthRequired: false,
|
||||
}
|
||||
|
||||
targetURL, _ := url.Parse("http://" + modelUpstream.Address)
|
||||
targetURL := &url.URL{Scheme: "http", Host: modelUpstream.Address}
|
||||
|
||||
route := &Route{
|
||||
Name: "v1-chat-" + modelName,
|
||||
@@ -169,3 +185,87 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
|
||||
return route, nil
|
||||
}
|
||||
|
||||
// extractTopLevelModel walks a JSON object body with a streaming decoder and
|
||||
// returns the raw value of the top-level "model" field. It never builds the
|
||||
// full document as a map, so a large "messages" array is skipped by token.
|
||||
// hasModel is false when the key is absent or the document is a bare null.
|
||||
func extractTopLevelModel(body []byte) (model json.RawMessage, hasModel bool, err error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if tok == nil {
|
||||
// A bare `null` body decoded to a nil map before, i.e. no model field.
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, false, fmt.Errorf("unexpected data after JSON null")
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||
return nil, false, fmt.Errorf("request body is not a JSON object")
|
||||
}
|
||||
for dec.More() {
|
||||
keyTok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
key, _ := keyTok.(string)
|
||||
if key == "model" {
|
||||
if err := dec.Decode(&model); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasModel = true
|
||||
continue
|
||||
}
|
||||
if err := skipJSONValue(dec); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
// Consume the closing brace and reject trailing data, preserving the
|
||||
// invalid_json answer the old whole-body decode produced.
|
||||
if _, err := dec.Token(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, false, fmt.Errorf("unexpected data after JSON object")
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return model, hasModel, nil
|
||||
}
|
||||
|
||||
// skipJSONValue advances dec past the next JSON value without materialising it.
|
||||
func skipJSONValue(dec *json.Decoder) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delim, ok := tok.(json.Delim)
|
||||
if !ok {
|
||||
return nil // scalar
|
||||
}
|
||||
if delim != '{' && delim != '[' {
|
||||
return fmt.Errorf("unexpected delimiter %v", delim)
|
||||
}
|
||||
depth := 1
|
||||
for depth > 0 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := tok.(json.Delim); ok {
|
||||
if d == '{' || d == '[' {
|
||||
depth++
|
||||
} else {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user