Skip to content

Commit 2b105ff

Browse files
feat(coordinator): async-broker step serving OpenAI compatible enqueue, wait, and passthrough modes (#2325)
* feat(coordinator): let pipeline steps register auxiliary HTTP routes Steps that implement RegisterRoutes(chi.Router) get it called once at server construction, after the built-in inference routes. This gives a step with its own HTTP surface (result retrieval, model listing) a home on the coordinator listener without the server learning step specifics. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * feat(coordinator): add async-broker step bridging llm-d-async Optional first pipeline step giving standard OpenAI clients access to request level queueing through the gateway. Clients opt in per request with a mode header: passthrough classifies against tenant quota and stamps objective and fairness headers, enqueue writes to the broker queue and answers 202 with the request id, wait holds the connection until the result lands. The step also serves GET and DELETE /v1/requests/{id} and GET /v1/models, with a reference doc and release note. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * feat(coordinator): configurable deadlines, wait caps, and post-fetch grace Per mode timeout bounds resolve field by field with 60s wait and 1h enqueue defaults, max_seconds clamps client requested deadlines, and holds that end at the deadline answer 504 DEADLINE_EXCEEDED. wait_cap_seconds ends a hold early with the 202 response, leaving the request fetchable. fetch_grace_seconds sets the mailbox TTL applied after a delivered fetch, and zero deletes the result on delivery. The doc's timeouts table is rewritten around clock spans. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * feat(coordinator): tenant scope AP-side keys and default the quota attribute The envelope id is now tenant:id, so the in-flight marker and cancellation key the AP derives from it are tenant scoped like the mailbox, and a fetch with the wrong tenant cannot learn that an id exists. The client-visible id is unchanged. The quota attribute defaults to userid to match the AP's redis-quota gate. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * test(coordinator): stop echoing the URL param in the aux route stub gosec flags writing a request-derived value straight to the response as G705 XSS. The stub only needs to prove the path param reached the handler, so capture it on the struct and assert there instead. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): state the async broker trust model and routing preconditions The tenant header is trusted as asserted, so drop the doc wording that implied tenant scoping alone prevents cross tenant probing. A deployment note now states that the request id is the only secret protecting a stored result and that clients wanting an unguessable handle should use the minted UUID. A second note covers gateway routing: stock llm-d only forwards the inference paths to the coordinator, so the fetch and models routes need adding to the HTTPRoute. Also corrects the RouteRegistrar comment, chi silently keeps the last handler for a duplicate pattern rather than panicking. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * fix(coordinator): harden async broker error paths and validation Review follow-ups from #2325: - Guard the stored result status code range before WriteHeader, a corrupted mailbox value now answers 502 instead of panicking into the recovery middleware - Re-read the mailbox once when the active marker is missing, closing the window where a result landing between the two reads produced a spurious 410 for a completed request - Stop writing raw Redis errors into client responses on fetch, delete, and enqueue failures, log them server side instead - Log quota release failures (the counter stays elevated until the window TTL) and surface result lookup errors during held waits - Use logger.Error for recoverable Redis failures across the step - Apply the enqueue path's tenant and id validation to the fetch and delete routes - Reject negative wait_cap_seconds, timeout bounds, quota window, and quota limits instead of silently reinterpreting them - Reject pipeline configs that place async-broker anywhere but first - Narrow the retry comment in the wait disconnect path, SubmitRequest clears markers but does not deduplicate a still queued original Adds tests for cross tenant DELETE, the malformed status guard, read path validation, quota fail open on Redis error, the negative config values, and builder ordering. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * feat(coordinator): cap wait deadlines at 1h and cancel on DELETE Wait mode holds a live connection per request, so an unclamped client requested deadline could pin one for days. timeouts.wait.max_seconds now defaults to 3600, raised to default_seconds when that is configured higher. Enqueue stays unclamped. DELETE /v1/requests/{id} now cancels a still queued request before reclaiming the mailbox, so the AP drops it pre-dispatch instead of running it and recreating the key the delete just removed. The cancel is a no-op for ids with no active marker. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): state the wait deadline cap once The clamp default belongs in the timeouts and TTLs table, not repeated in the config param table. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * fix(coordinator): support concurrent waits on one request id Two wait holds sharing a tenant and id (legal via retry by id) used to overwrite each other's wake channel, and the first to finish unsubscribed the notification channel the survivor still needed, silently degrading it to the backup poll. The waiter now keeps a wake list per channel, subscribes on the first waiter, and unsubscribes on the last, with the removal and unsubscribe under one lock so a concurrent registration re-subscribes in order. Covers the notify wake-up path with tests: the waiter multiplexing and refcounted unsubscribe directly, and a full wait hold in notify mode completing well under the backup poll interval. miniredis cannot emit keyspace notifications, so the tests publish the notification an LPUSH would fire. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): state each async broker fact once Review follow-up on duplication: the no-header no-op, the timeout defaults, and the fetch grace default were each restated across the doc, the config example, struct comments, and method comments. Each now has one home. Behavior facts live in the doc prose and the Execute comment, defaults live in the doc's param table and the config struct field comments the doc points at, and method comments describe only what their method adds. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * fix(coordinator): report mailbox presence with a bool The nilnil linter rejects readMailbox returning a nil result with a nil error for an empty mailbox, so it now returns an explicit found flag. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): state the id reuse contract for retries Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): broaden the id note to the retry contract Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * feat(coordinator): reattach retries to the live request instead of duplicating Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(coordinator): note the 499 answer for requests cancelled while queued Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * refactor(async-broker): reference shared header constants Use the epp metadata constants for the SLO, objective, and fairness header defaults instead of redefining the strings, pull the default mode header into a constant used by the tests, and reword the replace-media-urls comment in the example config so it no longer claims to be the first step. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * refactor(async-broker): move the step into a dedicated package The step spans five source files, so it moves from the flat steps package into pkg/coordinator/steps/asyncbroker. The type, constructor, and step-name constant become asyncbroker.Step, asyncbroker.New, and asyncbroker.StepName, and the builder's first-position check imports the new package. No behavior change. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * refactor(async-broker): drop the models list endpoint The gateway already forwards GET /v1/models to the model servers, so the step's listener serves only the result lifecycle routes. Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs(async-broker): state Redis topology, memory, and ACL requirements Signed-off-by: Benjamin Braun <benjaminbraun@google.com> * docs: move the release note to the PR description Signed-off-by: Benjamin Braun <benjaminbraun@google.com> --------- Signed-off-by: Benjamin Braun <benjaminbraun@google.com>
1 parent f3f3d61 commit 2b105ff

17 files changed

Lines changed: 2435 additions & 8 deletions

File tree

config/coordinator/coordinator.yaml

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,53 @@ pipeline:
101101

102102
steps:
103103
# -------------------------------------------------------------------
104-
# replace-media-urls: first step. Downloads any http(s) image_url
105-
# references, base64-inlines them as data: URIs, and seeds
106-
# MultimodalEntries on the request context.
104+
# async-broker: optional, and when enabled it must run first. Bridges
105+
# the coordinator to the llm-d-async broker. A request that carries the
106+
# mode header (default X-AP-Mode: passthrough | enqueue | wait) opts
107+
# into async serving: passthrough labels it (quota classification,
108+
# objective and fairness headers) and lets the pipeline continue;
109+
# enqueue answers 202 with the request id; wait holds the connection
110+
# until the result lands or the wait cap expires. Without the mode
111+
# header the step is a no-op. It also serves GET/DELETE
112+
# /v1/requests/{id}.
113+
# All params and their defaults are documented in
114+
# pkg/coordinator/steps/asyncbroker/config.go.
115+
# -------------------------------------------------------------------
116+
# - type: async-broker
117+
# params:
118+
# # The Redis holding the async processor's queues. Required.
119+
# redis_url: "redis://redis:6379"
120+
# # Routes select the broker queue and tier per (model, tenant);
121+
# # first match wins, empty fields match anything.
122+
# routes:
123+
# - model: "my-model"
124+
# queue: "team-a-queue"
125+
# tier: "interactive"
126+
# # Objectives stamped on passthrough requests by tier, selected
127+
# # by quota classification (reserved vs overflow).
128+
# objectives:
129+
# interactive:
130+
# reserved: "interactive-reserved"
131+
# overflow: "interactive-overflow"
132+
# # Reserved concurrency per tenant; tenants without an entry are
133+
# # always classified reserved. Counters are shared with the AP's
134+
# # redis-quota gate.
135+
# quota:
136+
# limits:
137+
# team-a: 8
138+
# # wait_cap_seconds bounds held wait connections, ending the hold
139+
# # with the 202 response. Holds otherwise run to the request
140+
# # deadline.
141+
# # wait_cap_seconds: 55
142+
# # fetch_grace_seconds is the mailbox TTL applied after a delivered
143+
# # fetch. Zero deletes the result on delivery.
144+
# # fetch_grace_seconds: 60
145+
146+
# -------------------------------------------------------------------
147+
# replace-media-urls: an optional step executed prior to request
148+
# content processing. Downloads any http(s) image_url references,
149+
# base64-inlines them as data: URIs, and seeds MultimodalEntries on
150+
# the request context.
107151
# -------------------------------------------------------------------
108152
- type: replace-media-urls
109153
params:

docs/coordinator_architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,7 @@ only the request carrier differs.
734734

735735
| `type` | Purpose | Key params |
736736
| :---- | :---- | :---- |
737+
| `async-broker` | Optional, first when enabled. Bridge to the [llm-d-async](https://github.com/llm-d/llm-d-async) broker: requests carrying the mode header are labeled and passed through (`passthrough`) or queued (`enqueue`, `wait`); requests without it are untouched. Also registers `GET/DELETE /v1/requests/{id}` on the listener. Full doc: [coordinator_async_broker.md](coordinator_async_broker.md). | `redis_url` (required), `routes`, `objectives`, `quota`, `wait_cap_seconds` |
737738
| `replace-media-urls` | Download `image_url` references, inline as base64 data URIs, seed `MultimodalEntries`. | `download_timeout`, `max_concurrent_downloads`, `max_multimodal_entries` |
738739
| `render` | Tokenize via the render service; populate `TokenIDs` and per-image hash/placeholder/kwargs. | `address` (required), `timeout`, `max_total_tokens`, `max_total_placeholder_tokens` |
739740
| `conditional-decode` | Optional fast path: attempt decode with `Prefer: if-available`; on 412 continue, otherwise stream the response and stop. | (none) |

docs/coordinator_async_broker.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Async Broker Step
2+
3+
The async-broker step bridges the coordinator to the [llm-d-async](https://github.com/llm-d/llm-d-async) broker, giving standard OpenAI clients access to request-level queueing through the gateway they already use. Clients opt in per request with a mode header, and requests without the header pass through the step untouched.
4+
5+
The step is optional and must run first in the pipeline when enabled. Queued requests re-enter the same pipeline on dispatch, so they stay eligible for everything the coordinator does for synchronous requests.
6+
7+
## Request modes
8+
9+
| Mode | Behavior | For |
10+
| :---- | :---- | :---- |
11+
| No header | Untouched, the normal request path | default behavior, AP dispatch re-entry |
12+
| `X-AP-Mode: passthrough` | Forwarded live with quota classification and objective and fairness stamping | live traffic tied to async tenant quota and priority |
13+
| `X-AP-Mode: enqueue` | Written to the broker queue, answers 202 plus id, result collected later by id | batch, deferred work |
14+
| `X-AP-Mode: wait` | Written to the broker queue, connection held until the result lands | request and response semantics over the queue |
15+
16+
## Request contract
17+
18+
Everything is communicated through headers on a standard OpenAI request, and payloads are not parsed. The step resolves the tenant from a header, classifies the request reserved or overflow against Redis quota counters using the same key scheme as the AP's redis-quota gate (one quota account per tenant across all modes when both sides use the same attribute), and expresses priority as InferenceObjective names the EPP understands. Objective and fairness headers are always stamped server side, so clients cannot self-assign priority.
19+
20+
```
21+
POST http://gateway:8081/v1/chat/completions
22+
Content-Type: application/json
23+
X-Team: premium # tenant (quota account, fairness id)
24+
X-AP-Mode: wait # passthrough | enqueue | wait
25+
X-Request-Id: job-4217 # optional, enables retry and fetch by id
26+
X-Request-Timeout-Seconds: 30 # optional deadline
27+
28+
{"model": "Qwen/Qwen3-0.6B", "messages": [{"role": "user", "content": "Summarize this."}]}
29+
```
30+
31+
An id names one logical request. Re-submitting an id reattaches to the live request or its stored result instead of running a second copy (reviving it if it was cancelled in-queue), so a retry with a different body gets the original body's response (don't do this). A retry runs fresh only once the previous attempt is fully dead: delivered, expired, or cancelled and dropped.
32+
33+
**Enqueue** returns immediately and the completion is collected later by id:
34+
35+
```
36+
HTTP/1.1 202 Accepted
37+
{"id": "job-4217", "status": "pending"}
38+
39+
GET http://gateway:8081/v1/requests/job-4217
40+
X-Team: premium # must match the enqueueing tenant
41+
42+
HTTP/1.1 200 OK # the model's response, upstream status mirrored
43+
{"id": "chatcmpl-...", "object": "chat.completion", "choices": [...]}
44+
45+
# still queued or executing: 202 {"id": "job-4217", "status": "pending"}
46+
# wrong tenant, expired TTL, or deleted: 410 Gone
47+
# cancelled while queued: 499 once the AP drops it, until the result TTL expires
48+
```
49+
50+
After a successful fetch delivery the result's TTL is shrunk to a grace window (`fetch_grace_seconds`), so a client that lost the response can re-fetch while unfetched results do not linger past the grace period.
51+
52+
**Wait** returns the model's response on the original connection with the upstream status mirrored, exactly as if the model server had answered directly, and the delivered result is deleted eagerly. Wake-up is a Redis keyspace notification on the result key, with a polling fallback when notifications are unavailable. The hold runs to the request deadline and answers 504 there, or ends early at `wait_cap_seconds` with the 202 response, leaving the request fetchable. If the client disconnects, the step cancels the request pre-dispatch.
53+
54+
**Passthrough** classifies and stamps, then lets the pipeline continue, so streaming and upstream errors behave exactly as they do without the step.
55+
56+
## Endpoints
57+
58+
The step registers two routes on the coordinator listener:
59+
60+
- `GET /v1/requests/{id}` fetches a queued result, tenant scoped and non-destructive
61+
- `DELETE /v1/requests/{id}` cancels a still queued request and reclaims its result. A request already dispatched runs to completion, and its result then sits out the mailbox TTL
62+
63+
## Broker state
64+
65+
On a queue named `foo`, step traffic and raw producer traffic share one sorted set and are indistinguishable to the AP's gates, lanes, and dispatch. Each message carries its own result destination in its envelope:
66+
67+
```
68+
foo request queue: shared, popped destructively in deadline order
69+
foo-results belt: raw producers' results, drained by their collector
70+
results:req:acme:job-4217 mailbox: one step result, read in place, expires via TTL
71+
request-active:acme:job-4217 in-flight marker: present means fetch answers pending
72+
```
73+
74+
A mailbox is the same list structure as a belt, holding exactly one result under a key named by (tenant, id). The in-flight marker holds a random per-request token, and cleanup is a compare-and-delete on that token, so a stale replica finishing an old request cannot clobber newer state.
75+
76+
## The AP side
77+
78+
The AP protocol is unchanged. Dispatches carry no mode header, so they re-enter the coordinator as ordinary requests and get phased to the EPP like any synchronous call.
79+
80+
A request keeps one client-visible id for its whole life: the validated `x-request-id` (or a minted UUID) is the fetch id and names the mailbox. The envelope id is that id prefixed with the tenant, so every AP-side key derived from it (the in-flight marker and the cancellation key) is tenant scoped, and one tenant's id choices cannot collide with another's. The dispatch call itself carries no `x-request-id`, so that hop logs under a fresh UUID in the coordinator, and `traceparent` on the envelope metadata is the join key between the two. Results are written to the message's mailbox with the configured TTL, and the list push fires the keyspace notification that completes any held wait. The step depends on three AP-side features from llm-d-async (result TTLs on queue config, per-lane objective and fairness stamping, and DEADLINE_EXCEEDED classification for deadline-aborted sends), see [llm-d-async#394](https://github.com/llm-d/llm-d-async/pull/394).
81+
82+
## Configuration
83+
84+
To enable the step, add this block as the first entry under `steps:` in the coordinator's pipeline config, and point `redis_url` at the Redis your async processor uses.
85+
86+
```yaml
87+
- type: async-broker
88+
params:
89+
redis_url: "redis://redis:6379"
90+
routes:
91+
- model: "my-model"
92+
queue: "team-a-queue"
93+
tier: "interactive"
94+
objectives:
95+
interactive:
96+
reserved: "interactive-reserved"
97+
overflow: "interactive-overflow"
98+
quota:
99+
limits:
100+
team-a: 8
101+
```
102+
103+
| Param | Default | Description |
104+
| :---- | :---- | :---- |
105+
| `redis_url` | required | the Redis holding the async processor's queues |
106+
| `mode_header` | `X-AP-Mode` | selects the serving mode per request |
107+
| `tenant_header` | `X-Team` | resolves the tenant (quota account, fairness id) |
108+
| `timeout_header` | `X-Request-Timeout-Seconds` | per-request deadline for queued modes |
109+
| `routes` | none | selects queue and tier per (model, tenant), first match wins, empty fields match anything |
110+
| `default_queue` | `request-sortedset` | queue for requests matching no route |
111+
| `objectives` | none | InferenceObjective names stamped per tier, selected by quota classification |
112+
| `quota` | prefix `quota:`, attribute `userid`, window 300s | reserved concurrency limits per tenant, counters shared with the AP's redis-quota gate. Tenants without an entry are always classified reserved |
113+
| `timeouts` | wait 60s, enqueue 1h | deadline bounds per queued mode. `max_seconds` caps client requested deadlines |
114+
| `wait_cap_seconds` | none | bounds held wait connections, ending the hold with the 202 response |
115+
| `fetch_grace_seconds` | 60 | mailbox TTL applied after a delivered fetch. Zero deletes the result on delivery |
116+
| `wakeup_mode` | `auto` | `notify`, `poll`, or `auto` which probes for keyspace notification support |
117+
| `forward_headers` | SLO headers | allowlisted client headers forwarded on queued messages. The mode, objective, and fairness headers are rejected here |
118+
119+
All params and their defaults are documented in `pkg/coordinator/steps/asyncbroker/config.go`, and a commented example lives in `config/coordinator/coordinator.yaml`.
120+
121+
## Timeouts and TTLs
122+
123+
| Clock | Runs from → until | Default | Where / Key | When it fires |
124+
| :---- | :---- | :---- | :---- | :---- |
125+
| Wait deadline | request accepted → result written to Redis | 60s | step param `timeouts.wait.default_seconds`, `X-Request-Timeout-Seconds` per request | hold answers 504 DEADLINE_EXCEEDED |
126+
| Enqueue deadline | request accepted (202) → result written to Redis | 1h | step param `timeouts.enqueue.default_seconds`, `X-Request-Timeout-Seconds` per request | fetch returns 504 DEADLINE_EXCEEDED |
127+
| Deadline clamp | applied once at admission, not a running clock | wait 1h, enqueue none | step param `timeouts.<mode>.max_seconds` | silently caps the requested deadline |
128+
| Wait hold cap | request accepted → result written to Redis or deadline | none | step param `wait_cap_seconds` | hold ends with 202 pending, still fetchable by id |
129+
| Per-dispatch attempt | AP worker sends the request → full response read back | 5m | AP flag `--request-timeout` | 504 DEADLINE_EXCEEDED, not retried |
130+
| Result TTL | result written to Redis → first fetch, expiry, or DELETE | none | AP queue config `result_ttl_seconds` | result deleted + fetch returns 410 Gone |
131+
| Post-fetch grace | first delivered fetch → grace expiry or DELETE | 60s | step param `fetch_grace_seconds` | result deleted + fetch returns 410 Gone |
132+
133+
The three lifecycle clocks hand off without overlap: the deadline ends where the result TTL begins (result written), and the result TTL ends where the grace begins (first delivered fetch). Wait mode deletes the result on delivery, so the TTL and grace rows apply to enqueue results and to wait requests that fell back at the cap. Raw producers supply a deadline per message, and their results go to the shared belt, which is drained destructively, so the TTL and grace rows do not apply there.
134+
135+
## Deployment notes
136+
137+
- The gateway must route `GET/DELETE /v1/requests/*` to the coordinator. Stock llm-d routing forwards only the inference paths, so these need adding to the coordinator's HTTPRoute.
138+
- The tenant header is trusted as asserted, the same as everywhere else on the llm-d serving path. Request id is the only secret protecting a stored result, so clients that need an unguessable handle should omit `X-Request-Id` and use the minted UUID.
139+
- Set `result_ttl_seconds` on every AP queue the step feeds, or unfetched results never expire.
140+
- Redis needs keyspace notifications enabled for the wait wake-up (`notify-keyspace-events Kl`). The step detects their absence and falls back to polling.
141+
- `redis_url` must point at a standalone Redis endpoint, or a proxy presenting one. The step's client does not follow Cluster redirects or Sentinel failovers.
142+
- Set `maxmemory` together with `maxmemory-policy noeviction` on that Redis, with headroom below the container's memory limit. An evicted marker, counter, or mailbox silently corrupts request state, while `noeviction` turns overflow into write errors the step reports.
143+
- A restricted Redis user needs `@scripting` and `@pubsub`. The `wakeup_mode: auto` probe also reads CONFIG, and setting `notify` explicitly avoids it.
144+
- Wait mode holds one gateway to coordinator connection per waiting client, so the gateway's circuit breaker limits on the coordinator cluster must be sized for held connections, not request rate. Envoy defaults are far too low.
145+
- `preserve_external_request_id` should be set on the gateway so client supplied request ids survive the hop for retry and fetch by id.
146+
- Delivery is at most once at any replica count. A message popped by an AP that then crashes is lost, and the client holds a pending id until its deadline expires. Delivery guarantees beyond this belong to client retries by id.

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ require (
2222
github.com/google/uuid v1.6.0
2323
github.com/hashicorp/golang-lru/v2 v2.0.7
2424
github.com/jellydator/ttlcache/v3 v3.4.1
25+
github.com/llm-d/llm-d-async/api v0.9.0
26+
github.com/llm-d/llm-d-async/producer v0.9.0
2527
github.com/onsi/ginkgo/v2 v2.28.3
2628
github.com/onsi/gomega v1.40.0
2729
github.com/openai/openai-go v1.12.0
2830
github.com/prometheus/client_golang v1.23.2
2931
github.com/prometheus/client_model v0.6.3
3032
github.com/prometheus/common v0.67.5
31-
github.com/redis/go-redis/v9 v9.20.1
33+
github.com/redis/go-redis/v9 v9.21.0
3234
github.com/spf13/pflag v1.0.10
3335
github.com/spf13/viper v1.21.0
3436
github.com/stretchr/testify v1.11.1

go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
151151
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
152152
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
153153
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
154+
github.com/llm-d/llm-d-async/api v0.9.0 h1:5kQD7UChMZtD/r8iED+uWm/ArlaIGLGCig1gtWQrtvM=
155+
github.com/llm-d/llm-d-async/api v0.9.0/go.mod h1:hzjFDTFBJEyW9/1vrHAD2dtCVLTzqggU1GBW+7YhxlQ=
156+
github.com/llm-d/llm-d-async/producer v0.9.0 h1:PL4l0RaL0zm6Rm7rC5m+iRu+QkzY2wcxJU+fP8uxc4U=
157+
github.com/llm-d/llm-d-async/producer v0.9.0/go.mod h1:c4vtdCfWFAdSKr0pUTI2wUWkkYQfgXJCihNhMc5DAKs=
154158
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
155159
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
156160
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -190,8 +194,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
190194
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
191195
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
192196
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
193-
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
194-
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
197+
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
198+
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
195199
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
196200
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
197201
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

pkg/coordinator/pipeline/builder/builder.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,21 @@ import (
2626
"github.com/llm-d/llm-d-router/pkg/coordinator/gateway"
2727
"github.com/llm-d/llm-d-router/pkg/coordinator/pipeline"
2828
"github.com/llm-d/llm-d-router/pkg/coordinator/steps"
29+
"github.com/llm-d/llm-d-router/pkg/coordinator/steps/asyncbroker"
2930
)
3031

3132
// validatePipeline rejects configurations that cannot work before any step runs.
32-
// The tokens-in format (use_openai_format=false) sends token IDs that only the
33-
// render step produces, so it requires a render step in the pipeline.
33+
// The async-broker step must intercept requests before any processing step
34+
// touches them (queued bodies are stored verbatim, and passthrough stamping
35+
// must precede routing), so it is only valid in first position. The tokens-in
36+
// format (use_openai_format=false) sends token IDs that only the render step
37+
// produces, so it requires a render step in the pipeline.
3438
func validatePipeline(p config.PipelineConfig) error {
39+
for i, s := range p.Steps {
40+
if s.Type == asyncbroker.StepName && i > 0 {
41+
return fmt.Errorf("the %q step must be the first pipeline step, found it at position %d", asyncbroker.StepName, i+1)
42+
}
43+
}
3544
if p.UseOpenAIFormat {
3645
return nil
3746
}

0 commit comments

Comments
 (0)