Skip to content

Commit d7220f8

Browse files
committed
Address review comments.
Signed-off-by: Revital Sur <eres@il.ibm.com>
1 parent 92ac161 commit d7220f8

7 files changed

Lines changed: 345 additions & 31 deletions

File tree

docs/communication.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ EPP-Phase: prefill
607607
`kwargs_data` carries the same per-image base64 tensors from the render step (same values sent to the encode stage). Each blob is a msgpack-serialized `MultiModalKwargsItem` containing both `pixel_values` and `image_grid_thw` (and any other model-specific keys). The prefill worker needs `image_grid_thw` to compute mRoPE (multimodal Rotary Position Embedding) positional encodings for the visual tokens.
608608

609609
> [!NOTE]
610-
> Due to a bug in the `/inference/v1/generate` implementation, top-level `kv_transfer_params` and `ec_transfer_params` are not propagated to the engine: the endpoint reads transfer parameters only from `sampling_params.extra_args` (the top-level `ec_transfer_params` is used solely to echo back in the response). The coordinator nests both under `extra_args`:
610+
> Due to a bug in the `/inference/v1/generate` implementation, top-level `kv_transfer_params` and `ec_transfer_params` are not propagated to the engine: the endpoint reads transfer parameters only from `sampling_params.extra_args`. The coordinator nests both under `extra_args`:
611611
612612
```
613613
POST <gateway>/inference/v1/generate
@@ -647,15 +647,27 @@ EPP-Phase: prefill
647647
```json
648648
{
649649
"request_id": "generate-tokens-abc123",
650-
"choices": [],
650+
"choices": [
651+
{"index": 0, "logprobs": null, "finish_reason": "length", "token_ids": [28715], "routed_experts": null}
652+
],
653+
"prompt_logprobs": null,
651654
"kv_transfer_params": {
652-
"block_id": "block-999",
653-
"peer_host": "10.0.0.42",
654-
"peer_port": 7777
655-
}
655+
"do_remote_prefill": true,
656+
"do_remote_decode": false,
657+
"remote_block_ids": [[1, 2, 4]],
658+
"remote_engine_id": "e1101616-bf27-4687-bd0b-05970390868e",
659+
"remote_request_id": "generate-tokens-abc123",
660+
"remote_host": "10.0.0.42",
661+
"remote_port": 5600,
662+
"tp_size": 1,
663+
"remote_num_tokens": 318
664+
},
665+
"ec_transfer_params": null
656666
}
657667
```
658668

669+
`kv_transfer_params` carries the remote-prefill handoff the decode worker needs to pull the KV cache (`remote_engine_id`, `remote_block_ids`, `remote_host`/`remote_port`, `remote_num_tokens`). `ec_transfer_params` is `null` in the prefill response.
670+
659671
---
660672

661673
### Option B: /v1/chat/completions

pkg/coordinator/steps/decode.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (s *DecodeStep) prepareDecodeBody(ctx context.Context, reqCtx *pipeline.Req
101101
if len(reqCtx.TokenIDs) > 0 {
102102
reqCtx.Body["prompt"] = reqCtx.TokenIDs
103103
}
104-
default:
104+
case gateway.FormatGenerate:
105105
// The /inference/v1/generate engine reads transfer params only from
106106
// sampling_params.extra_args; a top-level kv_transfer_params is ignored,
107107
// so the decode worker never pulls the prefill KV over NIXL. Merge into
@@ -111,12 +111,7 @@ func (s *DecodeStep) prepareDecodeBody(ctx context.Context, reqCtx *pipeline.Req
111111
sampling = map[string]any{}
112112
reqCtx.Body[reqcommon.FieldSamplingParams] = sampling
113113
}
114-
extraArgs, ok := sampling[reqcommon.FieldExtraArgs].(map[string]any)
115-
if !ok {
116-
extraArgs = map[string]any{}
117-
sampling[reqcommon.FieldExtraArgs] = extraArgs
118-
}
119-
extraArgs[reqcommon.FieldKVTransferParams] = kvParams
114+
setGenerateTransferParams(sampling, kvParams, nil)
120115
}
121116
}
122117

pkg/coordinator/steps/prefill.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -175,29 +175,25 @@ func (s *PrefillStep) buildPrefillBody(ctx context.Context, reqCtx *pipeline.Req
175175
}
176176
return body, nil
177177

178-
default:
178+
case gateway.FormatGenerate:
179179
// The /inference/v1/generate engine reads transfer params only from
180180
// sampling_params.extra_args; top-level fields are ignored on input.
181-
extraArgs := map[string]any{
182-
reqcommon.FieldKVTransferParams: kvParams,
183-
}
184-
if len(ecParams) > 0 {
185-
extraArgs[reqcommon.FieldECTransferParams] = ecParams
186-
}
181+
sampling := map[string]any{reqcommon.FieldMaxTokens: 1}
182+
setGenerateTransferParams(sampling, kvParams, ecParams)
187183
body := map[string]any{
188-
"request_id": reqCtx.RequestID,
189-
"token_ids": reqCtx.TokenIDs,
190-
"model": reqCtx.Model,
191-
reqcommon.FieldSamplingParams: map[string]any{
192-
reqcommon.FieldMaxTokens: 1,
193-
reqcommon.FieldExtraArgs: extraArgs,
194-
},
184+
"request_id": reqCtx.RequestID,
185+
"token_ids": reqCtx.TokenIDs,
186+
"model": reqCtx.Model,
187+
reqcommon.FieldSamplingParams: sampling,
195188
}
196189
if features != nil {
197190
body["features"] = features
198191
}
199192
return body, nil
200193
}
194+
// resolveFormat only ever yields the three formats above; a new value
195+
// reaching here is a programming error, not a client fault.
196+
return nil, fmt.Errorf("prefill: unsupported request format %v", format)
201197
}
202198

203199
type prefillResponse struct {

pkg/coordinator/steps/render.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ func (s *RenderStep) executeGenerate(ctx context.Context, reqCtx *pipeline.Reque
148148
}
149149
reqCtx.TokenIDs = tokenIDs
150150

151+
if err := validateSamplingParams(reqCtx.Body); err != nil {
152+
return fmt.Errorf("render: %w", err)
153+
}
154+
151155
rawFeatures := reqCtx.Body["features"]
152156
var features map[string]any
153157
if rawFeatures != nil {
@@ -161,6 +165,13 @@ func (s *RenderStep) executeGenerate(ctx context.Context, reqCtx *pipeline.Reque
161165
if err != nil {
162166
return fmt.Errorf("render: %w", err)
163167
}
168+
// The client supplies placeholder geometry directly on this path, so bound
169+
// every span to the prompt before EncodeStep allocates from length. Unlike
170+
// checkPlaceholderLimit, this guard is unconditional: it does not depend on
171+
// the optional max_total_placeholder_tokens knob.
172+
if err := validatePlaceholderBounds(entries, len(tokenIDs)); err != nil {
173+
return fmt.Errorf("render: %w", err)
174+
}
164175
if err := s.checkPlaceholderLimit(entries); err != nil {
165176
return err
166177
}
@@ -356,9 +367,13 @@ func (s *RenderStep) checkPlaceholderLimit(entries []pipeline.MultimodalEntry) e
356367
total := 0
357368
for _, e := range entries {
358369
total += e.Placeholder.Length
359-
}
360-
if total > s.maxTotalPlaceholderTokens {
361-
return fmt.Errorf("too many placeholder tokens: got %d, max %d: %w", total, s.maxTotalPlaceholderTokens, pipeline.ErrBadRequest)
370+
// total < 0 catches int overflow: lengths are non-negative, so a sum
371+
// past MaxInt wraps negative and would otherwise slip past the
372+
// total > max check, silently bypassing the limit. On the generate path
373+
// lengths come straight from the client, so this must not be evadable.
374+
if total < 0 || total > s.maxTotalPlaceholderTokens {
375+
return fmt.Errorf("too many placeholder tokens: got %d, max %d: %w", total, s.maxTotalPlaceholderTokens, pipeline.ErrBadRequest)
376+
}
362377
}
363378
return nil
364379
}

pkg/coordinator/steps/render_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"io"
24+
"math"
2425
"net/http"
2526
"net/http/httptest"
2627
"strings"
@@ -394,6 +395,31 @@ func TestRenderStep_AllowsAtPlaceholderLimit(t *testing.T) {
394395
}
395396
}
396397

398+
func TestRenderStep_PlaceholderLimitOverflow(t *testing.T) {
399+
step, err := NewRenderStep(nil, map[string]any{"max_total_placeholder_tokens": 5})
400+
if err != nil {
401+
t.Fatalf("NewRenderStep: %v", err)
402+
}
403+
rs := step.(*RenderStep)
404+
405+
// Two lengths whose sum overflows int and wraps negative. Without the
406+
// overflow guard, total > max is false and the limit is silently bypassed.
407+
entries := []pipeline.MultimodalEntry{
408+
{Placeholder: pipeline.PlaceholderRange{Length: math.MaxInt}},
409+
{Placeholder: pipeline.PlaceholderRange{Length: math.MaxInt}},
410+
}
411+
err = rs.checkPlaceholderLimit(entries)
412+
if err == nil {
413+
t.Fatal("expected error for overflowing placeholder length sum")
414+
}
415+
if !errors.Is(err, pipeline.ErrBadRequest) {
416+
t.Fatalf("expected ErrBadRequest, got %v", err)
417+
}
418+
if !strings.Contains(err.Error(), "too many placeholder tokens") {
419+
t.Fatalf("unexpected error: %v", err)
420+
}
421+
}
422+
397423
func TestRenderStep_RejectsNegativeLimits(t *testing.T) {
398424
if _, err := NewRenderStep(nil, map[string]any{"max_total_tokens": -1}); err == nil {
399425
t.Fatal("expected error for negative max_total_tokens")
@@ -555,6 +581,71 @@ func TestRenderStep_GenerateFormat_MalformedFeatures(t *testing.T) {
555581
}
556582
}
557583

584+
func TestRenderStep_GenerateFormat_PlaceholderOutOfBounds(t *testing.T) {
585+
for _, tc := range []struct {
586+
name string
587+
offset float64
588+
length float64
589+
}{
590+
{"offset_out_of_range", 5, 1},
591+
{"length_exceeds_prompt", 1, 9007199254740992},
592+
} {
593+
t.Run(tc.name, func(t *testing.T) {
594+
step, _ := NewRenderStep(nil, map[string]any{})
595+
reqCtx := &pipeline.RequestContext{
596+
OriginalPath: gateway.DefaultGeneratePath,
597+
Body: map[string]any{
598+
"model": "test-model",
599+
"token_ids": []any{float64(1), float64(32000), float64(32000), float64(2)},
600+
"features": map[string]any{
601+
"mm_hashes": map[string]any{"image": []any{"abc123"}},
602+
"mm_placeholders": map[string]any{"image": []any{
603+
map[string]any{"offset": tc.offset, "length": tc.length},
604+
}},
605+
"kwargs_data": map[string]any{"image": []any{"dGVuc29y"}},
606+
},
607+
},
608+
}
609+
err := step.Execute(context.Background(), reqCtx)
610+
if err == nil {
611+
t.Fatal("expected error for out-of-bounds placeholder")
612+
}
613+
if !errors.Is(err, pipeline.ErrBadRequest) {
614+
t.Errorf("expected ErrBadRequest, got %v", err)
615+
}
616+
})
617+
}
618+
}
619+
620+
func TestRenderStep_GenerateFormat_InvalidSamplingParams(t *testing.T) {
621+
for _, tc := range []struct {
622+
name string
623+
samplingParams any
624+
}{
625+
{"array", []any{float64(1), float64(2)}},
626+
{"string", "greedy"},
627+
} {
628+
t.Run(tc.name, func(t *testing.T) {
629+
step, _ := NewRenderStep(nil, map[string]any{})
630+
reqCtx := &pipeline.RequestContext{
631+
OriginalPath: gateway.DefaultGeneratePath,
632+
Body: map[string]any{
633+
"model": "test-model",
634+
"token_ids": []any{float64(1), float64(2), float64(3)},
635+
"sampling_params": tc.samplingParams,
636+
},
637+
}
638+
err := step.Execute(context.Background(), reqCtx)
639+
if err == nil {
640+
t.Fatal("expected error for non-object sampling_params")
641+
}
642+
if !errors.Is(err, pipeline.ErrBadRequest) {
643+
t.Errorf("expected ErrBadRequest, got %v", err)
644+
}
645+
})
646+
}
647+
}
648+
558649
func TestRenderStep_GenerateFormat_NullFeatures(t *testing.T) {
559650
step, _ := NewRenderStep(nil, map[string]any{})
560651
reqCtx := &pipeline.RequestContext{

pkg/coordinator/steps/utils.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/go-logr/logr"
2626

2727
logutil "github.com/llm-d/llm-d-router/pkg/common/observability/logging"
28+
reqcommon "github.com/llm-d/llm-d-router/pkg/common/request"
2829

2930
"github.com/llm-d/llm-d-router/pkg/coordinator/gateway"
3031
"github.com/llm-d/llm-d-router/pkg/coordinator/pipeline"
@@ -117,6 +118,24 @@ func mmKwargsField(kwargs []string) map[string][]any {
117118
return map[string][]any{ModalityImage: items}
118119
}
119120

121+
// setGenerateTransferParams nests the kv/ec transfer params under
122+
// sampling_params.extra_args, the only place the /inference/v1/generate engine
123+
// reads them (top-level kv_transfer_params/ec_transfer_params are ignored on
124+
// input). It get-or-creates extra_args on the given sampling map so a client's
125+
// existing generation fields survive. ecParams may be empty, in which case
126+
// ec_transfer_params is left unset.
127+
func setGenerateTransferParams(sampling map[string]any, kvParams any, ecParams map[string]any) {
128+
extraArgs, ok := sampling[reqcommon.FieldExtraArgs].(map[string]any)
129+
if !ok {
130+
extraArgs = map[string]any{}
131+
sampling[reqcommon.FieldExtraArgs] = extraArgs
132+
}
133+
extraArgs[reqcommon.FieldKVTransferParams] = kvParams
134+
if len(ecParams) > 0 {
135+
extraArgs[reqcommon.FieldECTransferParams] = ecParams
136+
}
137+
}
138+
120139
// coerceParamsMap coerces a transfer-params value from an upstream response to a
121140
// map: a non-object value is logged at debug and skipped (returns nil) rather
122141
// than failing the request. A missing or null value is already nil; an empty map
@@ -137,7 +156,8 @@ func coerceParamsMap(logger logr.Logger, v any, label string) map[string]any {
137156

138157
// toIntSlice converts a JSON-unmarshalled []any of numeric elements to []int.
139158
// Each element must be a non-negative integer represented as float64 or json.Number.
140-
// Callers wrap the returned error with their own field context.
159+
// The returned error identifies the offending element by index and wraps
160+
// pipeline.ErrBadRequest.
141161
func toIntSlice(values []any) ([]int, error) {
142162
out := make([]int, 0, len(values))
143163
for i, v := range values {
@@ -325,3 +345,57 @@ func extractMultimodalEntries(features map[string]any) ([]pipeline.MultimodalEnt
325345
}
326346
return entries, nil
327347
}
348+
349+
// validateSamplingParams checks that sampling_params and its nested extra_args,
350+
// when present, are JSON objects. Both are optional. The decode step merges
351+
// kv_transfer_params into sampling_params.extra_args; a non-object at either
352+
// level would fall into its fallback branch and be silently replaced with an
353+
// empty map, discarding client-requested generation parameters with no error.
354+
// Validating once at ingestion keeps that path fail-loud, consistent with
355+
// token_ids and features.
356+
func validateSamplingParams(body map[string]any) error {
357+
raw, ok := body[reqcommon.FieldSamplingParams]
358+
if !ok || raw == nil {
359+
return nil
360+
}
361+
sampling, ok := raw.(map[string]any)
362+
if !ok {
363+
return fmt.Errorf("%s must be an object, got %T: %w",
364+
reqcommon.FieldSamplingParams, raw, pipeline.ErrBadRequest)
365+
}
366+
ea, ok := sampling[reqcommon.FieldExtraArgs]
367+
if !ok || ea == nil {
368+
return nil
369+
}
370+
if _, ok := ea.(map[string]any); !ok {
371+
return fmt.Errorf("%s.%s must be an object, got %T: %w",
372+
reqcommon.FieldSamplingParams, reqcommon.FieldExtraArgs, ea, pipeline.ErrBadRequest)
373+
}
374+
return nil
375+
}
376+
377+
// validatePlaceholderBounds checks that every placeholder span [offset,
378+
// offset+length) lies within a prompt of tokenCount tokens. It guards the
379+
// generate path, where the client supplies placeholder geometry directly:
380+
// EncodeStep.buildEncodeTokenIDs indexes token_ids[offset] and allocates
381+
// make([]int, 1+length), so an out-of-range offset reads the wrong token and an
382+
// unbounded length (a tiny request can claim billions) is a memory-exhaustion
383+
// vector. vLLM declares offset/length as plain unbounded ints on the generate
384+
// endpoint and does not enforce this, so the coordinator does. offset and
385+
// length are already guaranteed non-negative by extractMultimodalEntries.
386+
func validatePlaceholderBounds(entries []pipeline.MultimodalEntry, tokenCount int) error {
387+
for i, e := range entries {
388+
off := e.Placeholder.Offset
389+
length := e.Placeholder.Length
390+
if off >= tokenCount {
391+
return fmt.Errorf("mm_placeholders[%d].offset %d out of range for %d token_ids: %w",
392+
i, off, tokenCount, pipeline.ErrBadRequest)
393+
}
394+
// off < tokenCount, so tokenCount-off is positive and cannot overflow.
395+
if length > tokenCount-off {
396+
return fmt.Errorf("mm_placeholders[%d] span (offset %d + length %d) exceeds %d token_ids: %w",
397+
i, off, length, tokenCount, pipeline.ErrBadRequest)
398+
}
399+
}
400+
return nil
401+
}

0 commit comments

Comments
 (0)