|
| 1 | +# Plugin Body Mode Capabilities |
| 2 | + |
| 3 | +Author(s): @noyitz |
| 4 | + |
| 5 | +## Proposal Status |
| 6 | +***Proposed*** |
| 7 | + |
| 8 | +## Summary |
| 9 | + |
| 10 | +This proposal introduces a **body mode capability declaration** mechanism for IPP plugins. Each plugin declares what level of access it needs to request and response bodies, enabling the framework to compute the optimal Envoy ext_proc `processing_mode` and validate it against the actual deployment configuration. |
| 11 | + |
| 12 | +Today, `request_body_mode` and `response_body_mode` are global Envoy settings applied to all plugins uniformly. This forces a "most demanding wins" approach at the infrastructure level — if any plugin needs the full response body, streaming breaks for everyone. This proposal moves the decision to the plugin layer, where the framework can reason about requirements, warn on misconfigurations, and enable streaming-compatible plugins to coexist with body-dependent ones. |
| 13 | + |
| 14 | +## Problem Statement |
| 15 | + |
| 16 | +The ext_proc `response_body_mode` is set to `FULL_DUPLEX_STREAMED` in the default MaaS deployment. The IPP framework accumulates all response body chunks in memory before processing ([server.go#L96-L146](https://github.com/llm-d/llm-d-inference-payload-processor/blob/main/pkg/handlers/server.go#L96)), which means: |
| 17 | + |
| 18 | +1. **Streaming is broken** — clients see all response chunks arrive at once after the full response is collected, resulting in multi-second TTFT for long responses |
| 19 | +2. **Memory is unbounded** — each concurrent request accumulates the full response body (`responseBody = append(responseBody, ...)`) with no upper limit. A 200K token streaming response is ~1MB in memory per request |
| 20 | +3. **All plugins pay the cost** — even plugins that don't need the response body (e.g., `apikey-injection`) force full buffering because the mode is global |
| 21 | + |
| 22 | +Different plugins have fundamentally different needs: |
| 23 | + |
| 24 | +| Plugin | Request Body | Response Body | Can Process Chunks? | |
| 25 | +|--------|-------------|---------------|-------------------| |
| 26 | +| `body-field-to-header` | Needs full body (parse JSON field) | Not needed | No | |
| 27 | +| `api-translation` | Needs full body (format conversion) | Needs full body (reverse conversion) | No | |
| 28 | +| `external-metering` | Not needed | Needs usage from final SSE event | Yes — only reads final chunk | |
| 29 | +| `apikey-injection` | Not needed | Not needed | N/A | |
| 30 | +| `nemo-guard` | Needs full body (content inspection) | Needs full body (output inspection) | No | |
| 31 | + |
| 32 | +## Design Principles |
| 33 | + |
| 34 | +- Plugins declare capabilities, the framework decides behavior |
| 35 | +- Existing plugins that don't declare capabilities continue to work unchanged (backward compatible) |
| 36 | +- The framework validates but does not silently override — operators make the final decision |
| 37 | +- Chunk processing is opt-in — plugins that can handle streaming declare it explicitly |
| 38 | + |
| 39 | +## Definitions |
| 40 | + |
| 41 | +- **BodyNeed** — what a plugin requires from a body (none, chunked, or buffered) |
| 42 | +- **ChunkProcessor** — a plugin that can process individual response body chunks as they stream through |
| 43 | +- **Effective mode** — the most demanding body need across all active plugins |
| 44 | +- **Mode mismatch** — when the Envoy config doesn't match the computed effective mode |
| 45 | + |
| 46 | +## Proposal |
| 47 | + |
| 48 | +### 1. Body Mode Declaration Interface |
| 49 | + |
| 50 | +Plugins optionally implement `BodyModeCapability` to declare their requirements: |
| 51 | + |
| 52 | +```go |
| 53 | +// BodyModeCapability allows plugins to declare what they need from request/response bodies. |
| 54 | +// Plugins that don't implement this interface default to BodyFull (backward compatible). |
| 55 | +type BodyModeCapability interface { |
| 56 | + BodyRequirements() BodyRequirements |
| 57 | +} |
| 58 | + |
| 59 | +type BodyRequirements struct { |
| 60 | + RequestBody BodyNeed |
| 61 | + ResponseBody BodyNeed |
| 62 | +} |
| 63 | + |
| 64 | +type BodyNeed int |
| 65 | + |
| 66 | +const ( |
| 67 | + // BodyNotNeeded — plugin only works with headers, doesn't read or modify the body. |
| 68 | + BodyNotNeeded BodyNeed = iota |
| 69 | + |
| 70 | + // BodyChunked — plugin can process individual body chunks as they arrive. |
| 71 | + // Plugins declaring this MUST also implement ChunkProcessor. |
| 72 | + // Streaming-compatible: client sees chunks in real-time. |
| 73 | + BodyChunked |
| 74 | + |
| 75 | + // BodyFull — plugin needs the complete body in memory before processing. |
| 76 | + // NOT streaming-compatible: client waits for full response. |
| 77 | + BodyFull |
| 78 | +) |
| 79 | +``` |
| 80 | + |
| 81 | +The plugin declares what IT needs — it doesn't know about Envoy modes. The framework maps plugin needs to Envoy configuration: |
| 82 | + |
| 83 | +| Highest plugin need | Envoy `body_mode` | |
| 84 | +|--------------------|-------------------| |
| 85 | +| All `BodyNotNeeded` | `NONE` | |
| 86 | +| Any `BodyChunked` | `STREAMED` | |
| 87 | +| Any `BodyFull` | `BUFFERED` | |
| 88 | + |
| 89 | +### 2. Chunk-Aware Response Processing |
| 90 | + |
| 91 | +Plugins that can handle streaming response chunks implement `ChunkProcessor`: |
| 92 | + |
| 93 | +```go |
| 94 | +// ChunkProcessor allows a plugin to process individual response body chunks |
| 95 | +// without waiting for the full body. The framework calls ProcessResponseChunk |
| 96 | +// for each chunk, then calls the standard ProcessResponse with the full |
| 97 | +// accumulated body on the final chunk. |
| 98 | +type ChunkProcessor interface { |
| 99 | + ProcessResponseChunk(ctx context.Context, cycleState *CycleState, chunk []byte, isFinal bool) ([]byte, error) |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +Constraints: |
| 104 | +- Plugins declaring `BodyChunked` MUST implement `ChunkProcessor` — validated at startup |
| 105 | +- `ProcessResponseChunk` returns modified bytes (to mutate the chunk forwarded to the client) or nil (pass through unchanged) |
| 106 | +- The standard `ProcessResponse` is still called on the final chunk with the full accumulated body — this is backward compatible and allows the plugin to do aggregated work (e.g., emit a metering event with total token counts) |
| 107 | + |
| 108 | +### 3. Effective Mode Computation |
| 109 | + |
| 110 | +At startup, after all plugins are loaded, the framework computes the effective body mode: |
| 111 | + |
| 112 | +``` |
| 113 | +Priority: NONE < CHUNKED < BUFFERED |
| 114 | +
|
| 115 | +For request body: |
| 116 | + If ANY plugin needs BUFFERED → effective = BUFFERED |
| 117 | + If ANY plugin needs CHUNKED → effective = CHUNKED (STREAMED in Envoy terms) |
| 118 | + If ALL plugins need NONE → effective = NONE |
| 119 | +
|
| 120 | +Same logic for response body, independently. |
| 121 | +``` |
| 122 | + |
| 123 | +Mapping to Envoy ext_proc modes: |
| 124 | + |
| 125 | +| Effective Need | Envoy `body_mode` | Behavior | |
| 126 | +|---------------|-------------------|----------| |
| 127 | +| `BodyNotNeeded` | `NONE` | Body not sent to ext_proc | |
| 128 | +| `BodyChunked` | `STREAMED` | Chunks sent one at a time, acked immediately | |
| 129 | +| `BodyFull` | `BUFFERED` | Full body buffered, sent as one message | |
| 130 | + |
| 131 | +### 4. Startup Validation |
| 132 | + |
| 133 | +The framework does **not** auto-override the Envoy configuration. It validates and reports: |
| 134 | + |
| 135 | +```go |
| 136 | +type ModeValidation struct { |
| 137 | + EffectiveRequestMode BodyNeed |
| 138 | + EffectiveResponseMode BodyNeed |
| 139 | + Warnings []string |
| 140 | + Errors []string |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +**Validation rules:** |
| 145 | + |
| 146 | +| Envoy Config | Effective Need | Result | Example | |
| 147 | +|-------------|---------------|--------|---------| |
| 148 | +| BUFFERED | CHUNKED | **WARNING**: streaming broken unnecessarily | "response_body_mode is BUFFERED but only STREAMED is needed — client streaming is disabled" | |
| 149 | +| BUFFERED | NONE | **WARNING**: unnecessary overhead | "response_body_mode is BUFFERED but no plugin needs the response body" | |
| 150 | +| NONE | CHUNKED | **ERROR**: plugin will fail | "external-metering requires response body but response_body_mode is NONE — token metering disabled" | |
| 151 | +| NONE | BUFFERED | **ERROR**: plugin will fail | "api-translation requires full response body but response_body_mode is NONE" | |
| 152 | +| STREAMED | BUFFERED | **ERROR**: plugin will get chunks, not full body | "api-translation requires full response body but response_body_mode is STREAMED" | |
| 153 | +| STREAMED | CHUNKED | OK | ✅ | |
| 154 | +| Config matches need | — | OK | ✅ | |
| 155 | + |
| 156 | +**Example startup log output:** |
| 157 | + |
| 158 | +``` |
| 159 | +INFO plugin body mode analysis |
| 160 | + computed_request_mode=BUFFERED |
| 161 | + computed_response_mode=CHUNKED |
| 162 | + request_body_plugins=["body-field-to-header", "api-translation"] |
| 163 | + response_body_plugins=["external-metering"] |
| 164 | + chunk_capable=["external-metering"] |
| 165 | +
|
| 166 | +WARN response_body_mode mismatch |
| 167 | + configured=FULL_DUPLEX_STREAMED recommended=STREAMED |
| 168 | + detail="Current mode accumulates all response chunks before forwarding. |
| 169 | + Use STREAMED for real-time streaming with token metering." |
| 170 | +
|
| 171 | +ERROR response_body_mode insufficient |
| 172 | + configured=NONE required=STREAMED |
| 173 | + plugin=external-metering |
| 174 | + detail="Plugin requires response body access for token extraction. |
| 175 | + Token metering will not work with current configuration." |
| 176 | +``` |
| 177 | + |
| 178 | +### 5. Streaming Response Processing Flow |
| 179 | + |
| 180 | +When `response_body_mode` is `STREAMED` and plugins declare `BodyChunked`: |
| 181 | + |
| 182 | +``` |
| 183 | +Backend streams response |
| 184 | + │ |
| 185 | + ▼ |
| 186 | +Envoy sends chunk₁ ──────────────────────────────────────────────────┐ |
| 187 | + │ │ |
| 188 | + Server.Process() │ |
| 189 | + ├── Accumulate: responseBody = append(responseBody, chunk₁) │ |
| 190 | + ├── ChunkProcessor plugins: ProcessResponseChunk(chunk₁) │ |
| 191 | + ├── Send ack to Envoy (BodyResponse{}) ─────────────────────┤ |
| 192 | + │ Envoy forwards │ |
| 193 | + │ chunk₁ to client │ |
| 194 | + ▼ │ |
| 195 | +Envoy sends chunk₂ ──────────────────────────────────────────────────┤ |
| 196 | + │ │ |
| 197 | + ├── Accumulate: responseBody = append(responseBody, chunk₂) │ |
| 198 | + ├── ChunkProcessor plugins: ProcessResponseChunk(chunk₂) │ |
| 199 | + ├── Send ack ───────────────────────────────────────────────┤ |
| 200 | + │ → chunk₂ to client |
| 201 | + ▼ │ |
| 202 | +Envoy sends chunk₃ (EndOfStream=true) ──────────────────────────────┘ |
| 203 | + │ |
| 204 | + ├── Accumulate final chunk |
| 205 | + ├── ChunkProcessor plugins: ProcessResponseChunk(chunk₃, isFinal=true) |
| 206 | + ├── Parse full accumulated body (JSON or SSE by Content-Type) |
| 207 | + ├── ResponseProcessor plugins: ProcessResponse(fullBody) |
| 208 | + └── Send final response to Envoy |
| 209 | +``` |
| 210 | + |
| 211 | +Key properties: |
| 212 | +- **Client gets real-time streaming** — each chunk is acked and forwarded immediately |
| 213 | +- **ChunkProcessor plugins** see each chunk in real-time for lightweight per-chunk work |
| 214 | +- **ResponseProcessor plugins** still get the full parsed body on the final chunk (backward compatible) |
| 215 | +- **Memory accumulation** still happens (for the final ProcessResponse call) — this is a known trade-off documented in the code. A future enhancement could allow chunk-only plugins to opt out of accumulation entirely |
| 216 | + |
| 217 | +### 6. Migration Path |
| 218 | + |
| 219 | +**Existing plugins** that don't implement `BodyModeCapability`: |
| 220 | +- Default to `{RequestBody: BodyFull, ResponseBody: BodyFull}` |
| 221 | +- Framework logs: `WARN plugin "X" does not declare body requirements, assuming BUFFERED for both` |
| 222 | +- No breaking changes — everything works exactly as before |
| 223 | + |
| 224 | +**New plugins** should implement `BodyModeCapability`. The framework validates `ChunkProcessor` implementation at startup: |
| 225 | +- Declares `BodyChunked` but doesn't implement `ChunkProcessor` → startup **ERROR** |
| 226 | +- Implements `ChunkProcessor` but declares `BodyFull` → `ChunkProcessor` methods are never called (no harm, but logged as INFO) |
| 227 | + |
| 228 | +### 7. Future: Dynamic Mode Override |
| 229 | + |
| 230 | +Envoy ext_proc supports `mode_override` in responses, allowing the server to change the processing mode per-request. A future enhancement could use this: |
| 231 | + |
| 232 | +1. During request processing, after the ProfilePicker selects a profile, check if the selected profile's response plugins need body access |
| 233 | +2. If no response plugin needs the body → override `response_body_mode` to `NONE` for this request |
| 234 | +3. This is a per-request optimization, not a global config change |
| 235 | + |
| 236 | +This is explicitly **out of scope** for the initial implementation. It requires careful testing and is opt-in via configuration: |
| 237 | + |
| 238 | +```yaml |
| 239 | +framework: |
| 240 | + dynamicModeOverride: false # default: validate only, don't override |
| 241 | +``` |
| 242 | +
|
| 243 | +### 8. Implementation Plan |
| 244 | +
|
| 245 | +| Phase | Scope | Files | |
| 246 | +|-------|-------|-------| |
| 247 | +| 1 | Add `BodyModeCapability` and `ChunkProcessor` interfaces | `pkg/framework/interface/requesthandling/plugins.go` | |
| 248 | +| 2 | Add `BodyRequirements` types | `pkg/framework/interface/requesthandling/types.go` | |
| 249 | +| 3 | Add validation logic at startup | `pkg/config/loader/configloader.go` | |
| 250 | +| 4 | Add chunk ack + ChunkProcessor dispatch in server loop | `pkg/handlers/server.go` | |
| 251 | +| 5 | Declare capabilities on existing plugins | Each plugin's `plugin.go` | |
| 252 | +| 6 | Add startup validation logging | `pkg/handlers/server.go` or `cmd/main.go` | |
| 253 | + |
| 254 | +### 9. Relation to PR #138 |
| 255 | + |
| 256 | +[PR #138](https://github.com/llm-d/llm-d-inference-payload-processor/pull/138) (SSE response parsing) addresses a subset of this proposal: |
| 257 | +- Adds chunk acks for non-final response body chunks (phase 4 above) |
| 258 | +- Adds SSE parsing for token extraction from streaming responses |
| 259 | +- Does NOT add the capability declaration or validation — plugins don't declare their needs |
| 260 | + |
| 261 | +This proposal builds on PR #138's streaming infrastructure and adds the framework-level intelligence to reason about plugin requirements. |
0 commit comments