Skip to main content

ts_client/
client.rs

1// Copyright (c) 2026 LightSeek Foundation
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in
11// all copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! Request submission, routing and output streams.
22
23use std::{
24    collections::HashMap,
25    sync::{
26        atomic::{AtomicUsize, Ordering},
27        Arc, Mutex, MutexGuard,
28    },
29    time::Duration,
30};
31
32use bytes::Bytes;
33use tokio::{
34    runtime::Handle,
35    sync::{mpsc, oneshot},
36    task::JoinHandle,
37    time::timeout,
38};
39use tracing::{debug, warn};
40use ts_wire::{
41    output::{FinishDetails, PromptTopLogprobs, TopLogprobs},
42    BatchTokenIDOutSlim, ControlOutput, ControlRequest, EngineOutput, EngineRequest, RequestOutput,
43    StrOrList, TokenizedGenerateReqInput,
44};
45use zeromq::{PullSocket, RouterRecvHalf, RouterSendHalf, SocketRecv, SocketSend, ZmqMessage};
46
47use crate::{
48    config::{ClientConfig, LoadBalance, Routing},
49    error::{Error, Result, StreamError},
50    handshake::{self, EngineInfo},
51};
52
53/// One step of a request's output.
54#[derive(Debug, Clone, PartialEq)]
55pub struct OutputChunk {
56    /// Token ids generated this step.
57    pub output_ids: Vec<i32>,
58    /// The finish type (`"stop"`, `"length"`, `"abort"`) on the last chunk.
59    pub finish_reason: Option<String>,
60    /// Prompt length.
61    pub prompt_tokens: i64,
62    /// Tokens generated so far.
63    pub completion_tokens: i64,
64    /// Prompt tokens served from the prefix cache.
65    pub cached_tokens: i64,
66    /// Logprobs of this step's tokens; empty when not requested.
67    pub output_token_logprobs: Vec<f64>,
68    /// The finish reason in full on the last chunk (the matched token, an
69    /// abort's message and codes), from an engine that sends it.
70    pub finish_details: Option<FinishDetails>,
71    /// Verify rounds so far, with speculative decoding.
72    pub spec_verify_ct: Option<i64>,
73    /// Accepted tokens per verify round once finished, with speculative
74    /// decoding.
75    pub accept_draft_tokens: Option<f64>,
76    /// The prompt logprobs (`None` for the first position) and their
77    /// tokens, in the one chunk that carries them.
78    pub input_token_logprobs: Option<(Vec<Option<f64>>, Vec<i32>)>,
79    /// The prompt positions' top-k alternatives, beside the prompt
80    /// logprobs.
81    pub input_top_logprobs: Option<PromptTopLogprobs>,
82    /// This step's tokens' top-k alternatives, when requested.
83    pub output_top_logprobs: Option<TopLogprobs>,
84}
85
86impl From<&RequestOutput<'_>> for OutputChunk {
87    fn from(row: &RequestOutput<'_>) -> Self {
88        fn owned<A: Clone, B: Clone>(pair: Option<(&[A], &[B])>) -> Option<(Vec<A>, Vec<B>)> {
89            pair.map(|(a, b)| (a.to_vec(), b.to_vec()))
90        }
91        Self {
92            output_ids: row.output_ids.to_vec(),
93            finish_reason: row.finish_reason.map(str::to_owned),
94            prompt_tokens: row.prompt_tokens,
95            completion_tokens: row.completion_tokens,
96            cached_tokens: row.cached_tokens,
97            output_token_logprobs: row.output_token_logprobs.to_vec(),
98            finish_details: row.finish_details.cloned(),
99            spec_verify_ct: row.spec_verify_ct,
100            accept_draft_tokens: row.accept_draft_tokens,
101            input_token_logprobs: owned(row.input_token_logprobs),
102            input_top_logprobs: owned(row.input_top_logprobs),
103            output_top_logprobs: owned(row.output_top_logprobs),
104        }
105    }
106}
107
108/// An engine's latest scheduler load, as reported with its outputs.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
110pub struct EngineLoad {
111    /// Running requests.
112    pub num_running: i64,
113    /// Waiting requests.
114    pub num_waiting: i64,
115    /// KV pages in use by active requests.
116    pub kv_active_pages: i64,
117    /// KV pages in total.
118    pub kv_total_pages: i64,
119}
120
121type StreamItem = std::result::Result<OutputChunk, StreamError>;
122
123struct Engine {
124    info: EngineInfo,
125    identity: Bytes,
126    /// This client's requests on the engine that have not finished.
127    in_flight: AtomicUsize,
128    load: Mutex<EngineLoad>,
129}
130
131struct Pending {
132    engine: usize,
133    tx: mpsc::UnboundedSender<StreamItem>,
134}
135
136/// A control operation awaiting every engine's reply.
137struct ControlWaiter {
138    replies: Vec<ControlOutput>,
139    done: oneshot::Sender<Vec<ControlOutput>>,
140}
141
142struct Shared {
143    engines: Vec<Engine>,
144    first_engine_index: u16,
145    routing: Routing,
146    send_timeout: Duration,
147    input: RouterSendHalf,
148    requests: Mutex<HashMap<String, Pending>>,
149    /// The control operation in flight per reply tag: replies carry no
150    /// engine or request id, so one operation of a kind is out at a time.
151    controls: Mutex<HashMap<&'static str, ControlWaiter>>,
152    /// Serializes the control operations of each kind.
153    control_turns: Mutex<HashMap<&'static str, Arc<tokio::sync::Mutex<()>>>>,
154    /// Set once the engines are gone; no request is accepted after.
155    closed: Mutex<Option<StreamError>>,
156    round_robin: AtomicUsize,
157    runtime: Handle,
158}
159
160fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
161    // A panic while holding one of these locks leaves plain data behind.
162    mutex
163        .lock()
164        .unwrap_or_else(|poisoned| poisoned.into_inner())
165}
166
167impl Shared {
168    fn closed(&self) -> Option<StreamError> {
169        lock(&self.closed).clone()
170    }
171
172    fn route(&self, request: &TokenizedGenerateReqInput) -> Result<usize> {
173        let count = self.engines.len();
174        match self.routing {
175            Routing::BootstrapRoom => {
176                let room = request.bootstrap_room.ok_or_else(|| {
177                    Error::InvalidRequest(
178                        "disaggregated serving needs a bootstrap_room on every request".to_owned(),
179                    )
180                })?;
181                Ok((room % count as u64) as usize)
182            }
183            Routing::Aggregated(policy) => {
184                if let Some(rank) = request.data_parallel_rank {
185                    return usize::try_from(rank)
186                        .ok()
187                        .filter(|rank| *rank < count)
188                        .ok_or_else(|| {
189                            Error::InvalidRequest(format!(
190                                "data_parallel_rank {rank} is outside 0..{count}"
191                            ))
192                        });
193                }
194                let start = self.round_robin.fetch_add(1, Ordering::Relaxed) % count;
195                Ok(match policy {
196                    LoadBalance::RoundRobin => start,
197                    // Scanning from the rotating start spreads ties.
198                    LoadBalance::LeastInFlight => (0..count)
199                        .map(|offset| (start + offset) % count)
200                        .min_by_key(|engine| {
201                            self.engines[*engine].in_flight.load(Ordering::Relaxed)
202                        })
203                        .expect("at least one engine"),
204                    LoadBalance::MinimumCacheUsage => (0..count)
205                        .map(|offset| (start + offset) % count)
206                        .min_by_key(|engine| {
207                            let engine = &self.engines[*engine];
208                            (
209                                lock(&engine.load).kv_active_pages,
210                                engine.in_flight.load(Ordering::Relaxed),
211                            )
212                        })
213                        .expect("at least one engine"),
214                })
215            }
216        }
217    }
218
219    async fn send(&self, engine: usize, frames: Vec<Bytes>) -> Result<()> {
220        let mut message = ZmqMessage::from(self.engines[engine].identity.clone());
221        for frame in frames {
222            message.push_back(frame);
223        }
224        let mut input = self.input.clone();
225        match timeout(self.send_timeout, input.send(message)).await {
226            Ok(sent) => Ok(sent?),
227            Err(_) => Err(Error::SendTimeout {
228                engine_index: self.engines[engine].info.index,
229                timeout: self.send_timeout,
230            }),
231        }
232    }
233
234    /// Stop tracking a request; returns its engine if it was in flight.
235    fn forget(&self, rid: &str) -> Option<usize> {
236        let pending = lock(&self.requests).remove(rid)?;
237        self.engines[pending.engine]
238            .in_flight
239            .fetch_sub(1, Ordering::Relaxed);
240        Some(pending.engine)
241    }
242
243    fn dispatch(&self, batch: &BatchTokenIDOutSlim) {
244        let slot = batch
245            .engine_index
246            .checked_sub(i64::from(self.first_engine_index))
247            .and_then(|slot| usize::try_from(slot).ok())
248            .filter(|slot| *slot < self.engines.len());
249        match slot {
250            Some(slot) => {
251                *lock(&self.engines[slot].load) = EngineLoad {
252                    num_running: batch.num_running,
253                    num_waiting: batch.num_waiting,
254                    kv_active_pages: batch.kv_active_pages,
255                    kv_total_pages: batch.kv_total_pages,
256                };
257            }
258            None => debug!(
259                engine_index = batch.engine_index,
260                "output from an unknown engine index"
261            ),
262        }
263
264        let rows = match batch.requests() {
265            Ok(rows) => rows,
266            Err(error) => {
267                warn!(%error, "dropping a malformed output batch");
268                return;
269            }
270        };
271        let mut requests = lock(&self.requests);
272        for row in rows {
273            let Some(pending) = requests.get(row.rid) else {
274                // Cancelled while its output was in flight.
275                debug!(rid = row.rid, "output for a request that is not in flight");
276                continue;
277            };
278            // A closed receiver means the stream is being dropped; its drop
279            // forgets the request.
280            let _ = pending.tx.send(Ok(OutputChunk::from(&row)));
281            if row.finish_reason.is_some() {
282                let pending = requests.remove(row.rid).expect("present above");
283                self.engines[pending.engine]
284                    .in_flight
285                    .fetch_sub(1, Ordering::Relaxed);
286            }
287        }
288    }
289
290    fn dispatch_control(&self, output: ControlOutput) {
291        let mut controls = lock(&self.controls);
292        let tag = output.tag();
293        let Some(waiter) = controls.get_mut(tag) else {
294            warn!(tag, "a control reply nobody waits for");
295            return;
296        };
297        waiter.replies.push(output);
298        if waiter.replies.len() == self.engines.len() {
299            let waiter = controls.remove(tag).expect("present above");
300            let _ = waiter.done.send(waiter.replies);
301        }
302    }
303
304    /// End every stream and refuse new requests.
305    fn close(&self, reason: StreamError) {
306        lock(&self.closed).get_or_insert_with(|| reason.clone());
307        // Dropping a waiter ends its operation with the reason.
308        lock(&self.controls).clear();
309        let drained: Vec<Pending> = lock(&self.requests).drain().map(|(_, p)| p).collect();
310        for pending in drained {
311            self.engines[pending.engine]
312                .in_flight
313                .fetch_sub(1, Ordering::Relaxed);
314            let _ = pending.tx.send(Err(reason.clone()));
315        }
316    }
317}
318
319/// A connection to one engine group: every attention-DP rank's engine.
320///
321/// Dropping the client ends every open stream with
322/// [`StreamError::ClientClosed`].
323pub struct EngineClient {
324    shared: Arc<Shared>,
325    tasks: Vec<JoinHandle<()>>,
326}
327
328/// The client's sockets, bound and waiting for the engines.
329///
330/// Binding first lets a launcher bind `tcp://<host>:0`, read the port the OS
331/// chose from [`ClientListener::handshake_address`], and start the engines
332/// with it.
333pub struct ClientListener {
334    config: ClientConfig,
335    bound: handshake::Bound,
336}
337
338impl ClientListener {
339    /// Bind the handshake, input and output sockets.
340    pub async fn bind(config: ClientConfig) -> Result<Self> {
341        let bound = handshake::bind(&config).await?;
342        Ok(Self { config, bound })
343    }
344
345    /// The handshake address the engines must dial, as bound.
346    pub fn handshake_address(&self) -> &str {
347        &self.bound.handshake_address
348    }
349
350    /// Wait until every engine has registered, within the configured
351    /// handshake timeout.
352    ///
353    /// Must be called within a Tokio runtime; the client's background tasks
354    /// run on it.
355    pub async fn accept(self) -> Result<EngineClient> {
356        let config = self.config;
357        let connected = handshake::accept(self.bound, &config).await?;
358        let (input, input_recv) = connected.input.split();
359        let engines = connected
360            .engines
361            .into_iter()
362            .map(|info| Engine {
363                identity: Bytes::copy_from_slice(&ts_wire::engine_identity(info.index)),
364                info,
365                in_flight: AtomicUsize::new(0),
366                load: Mutex::new(EngineLoad::default()),
367            })
368            .collect();
369        let shared = Arc::new(Shared {
370            engines,
371            first_engine_index: config.first_engine_index,
372            routing: config.routing,
373            send_timeout: config.send_timeout,
374            input,
375            requests: Mutex::new(HashMap::new()),
376            controls: Mutex::new(HashMap::new()),
377            control_turns: Mutex::new(HashMap::new()),
378            closed: Mutex::new(None),
379            round_robin: AtomicUsize::new(0),
380            runtime: Handle::current(),
381        });
382        let tasks = vec![
383            tokio::spawn(receive_outputs(shared.clone(), connected.output)),
384            tokio::spawn(drain_input(input_recv)),
385        ];
386        Ok(EngineClient { shared, tasks })
387    }
388}
389
390impl EngineClient {
391    /// Bind the frontend sockets and wait until every engine has registered.
392    /// See [`ClientListener`] to learn the bound address before waiting.
393    pub async fn connect(config: ClientConfig) -> Result<Self> {
394        ClientListener::bind(config).await?.accept().await
395    }
396
397    /// The engines, in index order, with the configuration each registered.
398    pub fn engines(&self) -> Vec<EngineInfo> {
399        self.shared.engines.iter().map(|e| e.info.clone()).collect()
400    }
401
402    /// Each engine's latest reported load, in index order.
403    pub fn loads(&self) -> Vec<EngineLoad> {
404        self.shared.engines.iter().map(|e| *lock(&e.load)).collect()
405    }
406
407    /// This client's unfinished requests per engine, in index order.
408    pub fn in_flight(&self) -> Vec<usize> {
409        self.shared
410            .engines
411            .iter()
412            .map(|e| e.in_flight.load(Ordering::Relaxed))
413            .collect()
414    }
415
416    /// Submit a request and return its output stream.
417    ///
418    /// The request's `rid` must be a single string, unique among requests
419    /// in flight. Dropping the stream before its last chunk aborts the
420    /// request on the engine.
421    pub async fn generate(&self, request: TokenizedGenerateReqInput) -> Result<RequestStream> {
422        let rid = match &request.rid {
423            Some(StrOrList::One(rid)) => rid.clone(),
424            _ => {
425                return Err(Error::InvalidRequest(
426                    "a generate request needs a single string rid".to_owned(),
427                ))
428            }
429        };
430        if let Some(reason) = self.shared.closed() {
431            return Err(Error::Unavailable(reason));
432        }
433        let engine = self.shared.route(&request)?;
434        let (tx, rx) = mpsc::unbounded_channel();
435        {
436            // Register before sending: outputs can arrive before `send`
437            // returns.
438            let mut requests = lock(&self.shared.requests);
439            if requests.contains_key(&rid) {
440                return Err(Error::DuplicateRequestId(rid));
441            }
442            requests.insert(rid.clone(), Pending { engine, tx });
443        }
444        self.shared.engines[engine]
445            .in_flight
446            .fetch_add(1, Ordering::Relaxed);
447        let frames = EngineRequest::Add(Box::new(request)).to_frames();
448        if let Err(error) = self.shared.send(engine, frames).await {
449            self.shared.forget(&rid);
450            return Err(error);
451        }
452        Ok(RequestStream {
453            shared: self.shared.clone(),
454            rid,
455            rx,
456            done: false,
457        })
458    }
459
460    /// Run a control operation on every engine and return their replies,
461    /// one per engine, in arrival order. Operations of one kind run one at
462    /// a time; some (a `wait` pause, a weight load) answer only when done,
463    /// so callers bound the wait themselves.
464    pub async fn control(&self, request: ControlRequest) -> Result<Vec<ControlOutput>> {
465        let tag = request.reply_tag();
466        let turn = lock(&self.shared.control_turns)
467            .entry(tag)
468            .or_default()
469            .clone();
470        let _turn = turn.lock().await;
471        if let Some(reason) = self.shared.closed() {
472            return Err(Error::Unavailable(reason));
473        }
474        let (done, replies) = oneshot::channel();
475        lock(&self.shared.controls).insert(
476            tag,
477            ControlWaiter {
478                replies: Vec::new(),
479                done,
480            },
481        );
482        let frames = EngineRequest::Control(Box::new(request)).to_frames();
483        for engine in 0..self.shared.engines.len() {
484            if let Err(error) = self.shared.send(engine, frames.clone()).await {
485                lock(&self.shared.controls).remove(tag);
486                return Err(error);
487            }
488        }
489        replies.await.map_err(|_| {
490            Error::Unavailable(self.shared.closed().unwrap_or(StreamError::ClientClosed))
491        })
492    }
493
494    /// Ask the engine to abort a request. Its stream then ends with an
495    /// `"abort"` chunk. Returns `false` if the request is not in flight.
496    pub async fn abort(&self, rid: &str) -> Result<bool> {
497        let engine = match lock(&self.shared.requests).get(rid) {
498            Some(pending) => pending.engine,
499            None => return Ok(false),
500        };
501        let frames = EngineRequest::Abort(vec![rid.to_owned()]).to_frames();
502        self.shared.send(engine, frames).await?;
503        Ok(true)
504    }
505}
506
507impl Drop for EngineClient {
508    fn drop(&mut self) {
509        for task in &self.tasks {
510            task.abort();
511        }
512        self.shared.close(StreamError::ClientClosed);
513    }
514}
515
516/// The outputs of one request, in order.
517pub struct RequestStream {
518    shared: Arc<Shared>,
519    rid: String,
520    rx: mpsc::UnboundedReceiver<StreamItem>,
521    done: bool,
522}
523
524impl RequestStream {
525    /// The request id.
526    pub fn rid(&self) -> &str {
527        &self.rid
528    }
529
530    /// The next chunk; `None` after the chunk with a finish reason or an
531    /// error.
532    pub async fn next(&mut self) -> Option<StreamItem> {
533        if self.done {
534            return None;
535        }
536        let item = self.rx.recv().await;
537        self.done = match &item {
538            Some(Ok(chunk)) => chunk.finish_reason.is_some(),
539            Some(Err(_)) | None => true,
540        };
541        item
542    }
543}
544
545impl Drop for RequestStream {
546    fn drop(&mut self) {
547        if self.done {
548            return;
549        }
550        let Some(engine) = self.shared.forget(&self.rid) else {
551            return;
552        };
553        let shared = self.shared.clone();
554        let frames = EngineRequest::Abort(vec![self.rid.clone()]).to_frames();
555        let rid = self.rid.clone();
556        self.shared.runtime.spawn(async move {
557            if let Err(error) = shared.send(engine, frames).await {
558                warn!(rid, %error, "failed to abort a dropped request");
559            }
560        });
561    }
562}
563
564async fn receive_outputs(shared: Arc<Shared>, mut output: PullSocket) {
565    loop {
566        let message = match output.recv().await {
567            Ok(message) => message,
568            Err(error) => {
569                // A peer error here is an engine's output connection
570                // breaking, as when the process is killed.
571                warn!(%error, "output socket failed");
572                shared.close(StreamError::Transport(error.to_string()));
573                return;
574            }
575        };
576        match EngineOutput::from_frames(&message.into_vec()) {
577            Ok(EngineOutput::Batch(batch)) => shared.dispatch(&batch),
578            Ok(EngineOutput::Control(output)) => shared.dispatch_control(*output),
579            Ok(EngineOutput::Dead) => {
580                warn!("an engine shut down");
581                shared.close(StreamError::EngineDead);
582                return;
583            }
584            Err(error) => warn!(%error, "dropping a malformed output message"),
585        }
586    }
587}
588
589/// Engines send nothing on the input socket after registering. Receiving
590/// keeps the socket's peer bookkeeping current.
591async fn drain_input(mut input: RouterRecvHalf) {
592    loop {
593        match input.recv().await {
594            Ok(message) => warn!(
595                frames = message.len(),
596                "unexpected message on the input socket"
597            ),
598            Err(error) => {
599                debug!(%error, "input socket receive ended");
600                return;
601            }
602        }
603    }
604}