Skip to main content

github_copilot_sdk/
copilot_request_handler.rs

1//! Connection-level interception of the model-layer HTTP and WebSocket traffic
2//! the runtime issues — for both CAPI and BYOK sessions.
3//!
4//! When [`ClientOptions::request_handler`](crate::ClientOptions::request_handler)
5//! is set, the SDK registers itself as the runtime's request handler on
6//! [`Client::start`](crate::Client::start). From then on, whenever the runtime
7//! would issue a model-layer request (inference, `/models`, `/policy`, …) it
8//! asks the registered [`CopilotRequestHandler`] to service it instead of making
9//! the call itself.
10//!
11//! [`CopilotRequestHandler`] is the single seam consumers implement: one HTTP
12//! send method and one WebSocket factory, each defaulting to transparent
13//! pass-through to the real upstream. Override
14//! [`send_request`](CopilotRequestHandler::send_request) to mutate / replace HTTP
15//! requests, or [`open_websocket`](CopilotRequestHandler::open_websocket) to
16//! mutate the handshake or return a custom [`CopilotWebSocketHandler`].
17//!
18//! # Cancellation
19//!
20//! [`CopilotRequestContext::cancel`] fires when the runtime cancels the
21//! in-flight request (for example because the agent turn was aborted). Forward
22//! it to the upstream call so it is torn down too, and stop writing the response.
23
24use std::collections::HashMap;
25use std::pin::Pin;
26use std::sync::{Arc, LazyLock, OnceLock, Weak};
27
28use async_trait::async_trait;
29use base64::Engine;
30use bytes::Bytes;
31use futures_util::{SinkExt, Stream, StreamExt};
32use http::HeaderMap;
33use http::header::{HeaderName, HeaderValue};
34use parking_lot::Mutex;
35use tokio::net::TcpStream;
36use tokio::sync::{Mutex as AsyncMutex, mpsc};
37use tokio_tungstenite::tungstenite::Message;
38use tokio_tungstenite::tungstenite::client::IntoClientRequest;
39use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
40use tokio_util::sync::CancellationToken;
41use tracing::warn;
42
43use crate::generated::api_types::{
44    LlmInferenceHttpRequestChunkRequest, LlmInferenceHttpRequestStartRequest,
45    LlmInferenceHttpRequestStartTransport, LlmInferenceHttpResponseChunkError,
46    LlmInferenceHttpResponseChunkRequest, LlmInferenceHttpResponseStartRequest,
47};
48use crate::{
49    Client, ClientInner, JsonRpcRequest, JsonRpcResponse, RequestId, SessionId, error_codes,
50};
51
52const METHOD_HTTP_REQUEST_START: &str = "llmInference.httpRequestStart";
53const METHOD_HTTP_REQUEST_CHUNK: &str = "llmInference.httpRequestChunk";
54
55/// Transport the runtime would otherwise use for an intercepted request.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum CopilotRequestTransport {
58    /// Plain HTTP or SSE. Each response body frame is an opaque byte range.
59    #[default]
60    Http,
61    /// Full-duplex WebSocket. Each request/response body frame maps to exactly
62    /// one WebSocket message.
63    WebSocket,
64}
65
66impl CopilotRequestTransport {
67    fn from_wire(value: Option<LlmInferenceHttpRequestStartTransport>) -> Self {
68        match value {
69            Some(LlmInferenceHttpRequestStartTransport::Websocket) => Self::WebSocket,
70            _ => Self::Http,
71        }
72    }
73}
74
75/// Error returned by a [`CopilotRequestHandler`] hook or the response stream.
76#[derive(Debug)]
77#[non_exhaustive]
78pub enum CopilotRequestError {
79    /// The response was used after the RPC connection to the runtime closed.
80    ConnectionClosed,
81
82    /// The response state machine was violated (for example `start` called
83    /// twice, or a write before `start`).
84    InvalidState(String),
85
86    /// An upstream transport failure while forwarding the request.
87    Upstream(String),
88
89    /// A failure surfaced by the consumer's own handler.
90    Handler(String),
91
92    /// An RPC error talking to the runtime.
93    Rpc(crate::Error),
94}
95
96impl CopilotRequestError {
97    /// Construct a handler-level error from a message — the idiomatic way for a
98    /// consumer to fail an intercepted request.
99    pub fn message(message: impl Into<String>) -> Self {
100        Self::Handler(message.into())
101    }
102}
103
104impl std::fmt::Display for CopilotRequestError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::ConnectionClosed => {
108                f.write_str("Copilot request response used after RPC connection closed")
109            }
110            Self::InvalidState(message) | Self::Upstream(message) | Self::Handler(message) => {
111                f.write_str(message)
112            }
113            Self::Rpc(err) => write!(f, "{err}"),
114        }
115    }
116}
117
118impl std::error::Error for CopilotRequestError {
119    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
120        match self {
121            Self::Rpc(err) => Some(err),
122            _ => None,
123        }
124    }
125}
126
127impl From<crate::Error> for CopilotRequestError {
128    fn from(err: crate::Error) -> Self {
129        Self::Rpc(err)
130    }
131}
132
133/// Context describing an intercepted request, shared by the HTTP and WebSocket
134/// seams.
135#[derive(Clone)]
136#[non_exhaustive]
137pub struct CopilotRequestContext {
138    /// Opaque runtime-minted request id, stable across the request lifecycle.
139    pub request_id: String,
140    /// Id of the runtime session that triggered this request, or `None` when it
141    /// was issued outside any session (for example the startup model catalog).
142    pub session_id: Option<String>,
143    /// Stable per-agent-instance id for the agent trajectory that issued this request.
144    pub agent_id: Option<String>,
145    /// Id of the parent agent when this request was issued by a subagent.
146    pub parent_agent_id: Option<String>,
147    /// Runtime classification for the interaction that produced this request.
148    pub interaction_type: Option<String>,
149    /// Transport the runtime would otherwise use.
150    pub transport: CopilotRequestTransport,
151    /// Absolute request URL.
152    pub url: String,
153    /// Request headers, multi-valued.
154    pub headers: HeaderMap,
155    /// Fires when the runtime cancels this in-flight request.
156    pub cancel: CancellationToken,
157}
158
159/// Streaming response body: a sequence of byte chunks or a terminal error.
160pub type CopilotHttpResponseBody =
161    Pin<Box<dyn Stream<Item = Result<Bytes, CopilotRequestError>> + Send>>;
162
163/// A buffered HTTP request handed to [`CopilotRequestHandler::send_request`].
164#[non_exhaustive]
165pub struct CopilotHttpRequest {
166    /// HTTP method (`GET`, `POST`, …).
167    pub method: String,
168    /// Absolute request URL.
169    pub url: String,
170    /// Request headers.
171    pub headers: HeaderMap,
172    /// Fully-buffered request body.
173    pub body: Vec<u8>,
174    /// Fires when the runtime cancels the request.
175    pub cancel: CancellationToken,
176}
177
178/// A streaming HTTP response returned by [`CopilotRequestHandler::send_request`].
179#[non_exhaustive]
180pub struct CopilotHttpResponse {
181    /// HTTP status code.
182    pub status: u16,
183    /// Optional status reason phrase.
184    pub status_text: Option<String>,
185    /// Response headers.
186    pub headers: HeaderMap,
187    /// Streaming response body.
188    pub body: CopilotHttpResponseBody,
189}
190
191impl CopilotHttpResponse {
192    /// Build a response with the given parts.
193    pub fn new(
194        status: u16,
195        status_text: Option<String>,
196        headers: HeaderMap,
197        body: CopilotHttpResponseBody,
198    ) -> Self {
199        Self {
200            status,
201            status_text,
202            headers,
203            body,
204        }
205    }
206}
207
208/// A single WebSocket message flowing through a [`CopilotWebSocketHandler`].
209#[derive(Clone)]
210pub struct CopilotWebSocketMessage {
211    /// Message payload.
212    pub data: Vec<u8>,
213    /// Whether the payload is a binary frame (`true`) or a text frame (`false`).
214    pub binary: bool,
215}
216
217impl CopilotWebSocketMessage {
218    /// A UTF-8 text message. Binary messages are constructed directly via the
219    /// public `data` / `binary` fields.
220    pub fn from_text(data: impl Into<String>) -> Self {
221        Self {
222            data: data.into().into_bytes(),
223            binary: false,
224        }
225    }
226}
227
228/// The runtime-facing side of a WebSocket: a [`CopilotWebSocketHandler`] writes
229/// upstream→runtime messages here.
230#[derive(Clone)]
231pub struct CopilotWebSocketResponse {
232    exchange: Arc<CopilotRequestExchange>,
233}
234
235impl CopilotWebSocketResponse {
236    fn new(exchange: Arc<CopilotRequestExchange>) -> Self {
237        Self { exchange }
238    }
239
240    /// Forward one upstream message to the runtime.
241    pub async fn send_message(
242        &self,
243        message: CopilotWebSocketMessage,
244    ) -> Result<(), CopilotRequestError> {
245        self.exchange.ensure_ws_started().await?;
246        if message.binary {
247            self.exchange.write_binary(&message.data).await
248        } else {
249            let text = String::from_utf8_lossy(&message.data);
250            self.exchange.write_text(&text).await
251        }
252    }
253
254    /// End the runtime response stream (the upstream connection closed).
255    pub async fn close(&self) -> Result<(), CopilotRequestError> {
256        self.exchange.end_response().await
257    }
258
259    async fn fail(
260        &self,
261        message: impl Into<String>,
262        code: Option<String>,
263    ) -> Result<(), CopilotRequestError> {
264        self.exchange.error_response(message, code).await
265    }
266}
267
268/// A per-connection WebSocket handler. The default implementation
269/// ([`CopilotWebSocketForwarder`]) bridges to the real upstream;
270/// override [`CopilotRequestHandler::open_websocket`] to supply a custom one.
271#[async_trait]
272pub trait CopilotWebSocketHandler: Send + Sync {
273    /// Forward one runtime→upstream message.
274    async fn send_request_message(
275        &self,
276        message: CopilotWebSocketMessage,
277    ) -> Result<(), CopilotRequestError>;
278
279    /// Tear down the upstream connection.
280    async fn close(&self) -> Result<(), CopilotRequestError>;
281}
282
283/// The connection-level Copilot request seam.
284///
285/// One implementor services both transports. Defaults forward transparently to
286/// the real upstream, so overriding nothing yields a pass-through; override a
287/// method to mutate or replace traffic.
288#[async_trait]
289pub trait CopilotRequestHandler: Send + Sync + 'static {
290    /// Service one intercepted HTTP request. Default: forward to the real
291    /// upstream via [`forward_http`]. Override to mutate the request before
292    /// forwarding, mutate the response after, or replace the call entirely.
293    async fn send_request(
294        &self,
295        request: CopilotHttpRequest,
296        _ctx: &CopilotRequestContext,
297    ) -> Result<CopilotHttpResponse, CopilotRequestError> {
298        forward_http(request).await
299    }
300
301    /// Open a per-connection WebSocket handler. Default: a
302    /// [`CopilotWebSocketForwarder`] wired to the real upstream.
303    /// Override to mutate the handshake (URL / headers via `ctx`) or return a
304    /// custom handler.
305    ///
306    /// Unlike the other SDKs, Rust passes `response` — the runtime-facing sink
307    /// for upstream→runtime messages — as a second argument here rather than
308    /// exposing a base-class `send_response_message` helper. A custom handler
309    /// must store this `CopilotWebSocketResponse` in the returned handler struct
310    /// and call [`CopilotWebSocketResponse::send_message`] on it to push
311    /// upstream messages back to the runtime.
312    async fn open_websocket(
313        &self,
314        ctx: &CopilotRequestContext,
315        response: CopilotWebSocketResponse,
316    ) -> Result<Box<dyn CopilotWebSocketHandler>, CopilotRequestError> {
317        let handler = CopilotWebSocketForwarder::builder(ctx.url.clone(), ctx.headers.clone())
318            .connect(response)
319            .await?;
320        Ok(Box::new(handler))
321    }
322}
323
324/// Forward through a shared handler, so an `Arc<H>` can be registered while the
325/// consumer retains a handle (for example to read state the handler records).
326#[async_trait]
327impl<H: CopilotRequestHandler> CopilotRequestHandler for Arc<H> {
328    async fn send_request(
329        &self,
330        request: CopilotHttpRequest,
331        ctx: &CopilotRequestContext,
332    ) -> Result<CopilotHttpResponse, CopilotRequestError> {
333        (**self).send_request(request, ctx).await
334    }
335
336    async fn open_websocket(
337        &self,
338        ctx: &CopilotRequestContext,
339        response: CopilotWebSocketResponse,
340    ) -> Result<Box<dyn CopilotWebSocketHandler>, CopilotRequestError> {
341        (**self).open_websocket(ctx, response).await
342    }
343}
344/// fresh upstream connection.
345const FORBIDDEN_HEADERS: &[&str] = &[
346    "host",
347    "connection",
348    "content-length",
349    "transfer-encoding",
350    "keep-alive",
351    "upgrade",
352    "proxy-connection",
353    "te",
354    "trailer",
355];
356
357fn is_forbidden_header(name: &HeaderName) -> bool {
358    let name = name.as_str();
359    FORBIDDEN_HEADERS.contains(&name) || name.starts_with("sec-websocket")
360}
361
362/// Drop headers that belong to the inbound connection rather than the request.
363fn strip_forbidden_headers(headers: &mut HeaderMap) {
364    let forbidden: Vec<HeaderName> = headers
365        .keys()
366        .filter(|name| is_forbidden_header(name))
367        .cloned()
368        .collect();
369    for name in forbidden {
370        headers.remove(&name);
371    }
372}
373
374static SHARED_HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
375    reqwest::Client::builder()
376        .redirect(reqwest::redirect::Policy::none())
377        .build()
378        .expect("default reqwest client must build")
379});
380
381/// Forward an HTTP request to its real upstream and stream the response back.
382///
383/// This is the default behaviour of [`CopilotRequestHandler::send_request`];
384/// consumers that mutate a request can call it to forward the mutated request.
385pub async fn forward_http(
386    request: CopilotHttpRequest,
387) -> Result<CopilotHttpResponse, CopilotRequestError> {
388    let method = reqwest::Method::from_bytes(request.method.as_bytes())
389        .map_err(|e| CopilotRequestError::InvalidState(format!("invalid HTTP method: {e}")))?;
390
391    let mut headers = request.headers;
392    strip_forbidden_headers(&mut headers);
393
394    let mut builder = SHARED_HTTP_CLIENT
395        .request(method, &request.url)
396        .headers(headers);
397    if !request.body.is_empty() {
398        builder = builder.body(request.body);
399    }
400
401    let response = tokio::select! {
402        _ = request.cancel.cancelled() => {
403            return Err(CopilotRequestError::message("Request cancelled by runtime"));
404        }
405        result = builder.send() => result.map_err(|e| CopilotRequestError::Upstream(e.to_string()))?,
406    };
407
408    let status = response.status().as_u16();
409    let status_text = response.status().canonical_reason().map(str::to_string);
410    let headers = response.headers().clone();
411    let body = response
412        .bytes_stream()
413        .map(|item| item.map_err(|e| CopilotRequestError::Upstream(e.to_string())));
414
415    Ok(CopilotHttpResponse {
416        status,
417        status_text,
418        headers,
419        body: Box::pin(body),
420    })
421}
422
423type UpstreamWrite =
424    futures_util::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
425
426/// Transform applied to a WebSocket message; return `None` to drop it.
427pub type WebSocketTransform =
428    Arc<dyn Fn(CopilotWebSocketMessage) -> Option<CopilotWebSocketMessage> + Send + Sync>;
429
430/// Builder for a [`CopilotWebSocketForwarder`].
431pub struct CopilotWebSocketForwarderBuilder {
432    url: String,
433    headers: HeaderMap,
434    on_send_request_message: Option<WebSocketTransform>,
435    on_send_response_message: Option<WebSocketTransform>,
436}
437
438impl CopilotWebSocketForwarderBuilder {
439    /// Hook runtime→upstream messages (mutate or drop before forwarding).
440    pub fn on_send_request_message(mut self, transform: WebSocketTransform) -> Self {
441        self.on_send_request_message = Some(transform);
442        self
443    }
444
445    /// Hook upstream→runtime messages (mutate or drop before forwarding).
446    pub fn on_send_response_message(mut self, transform: WebSocketTransform) -> Self {
447        self.on_send_response_message = Some(transform);
448        self
449    }
450
451    /// Dial the upstream WebSocket and begin pumping upstream→runtime messages
452    /// into `response`.
453    pub async fn connect(
454        self,
455        response: CopilotWebSocketResponse,
456    ) -> Result<CopilotWebSocketForwarder, CopilotRequestError> {
457        let mut request =
458            self.url.as_str().into_client_request().map_err(|e| {
459                CopilotRequestError::Upstream(format!("invalid websocket url: {e}"))
460            })?;
461        for (name, value) in &self.headers {
462            if is_forbidden_header(name) {
463                continue;
464            }
465            request.headers_mut().append(name.clone(), value.clone());
466        }
467
468        let (stream, _) = connect_async(request)
469            .await
470            .map_err(|e| CopilotRequestError::Upstream(format!("websocket connect failed: {e}")))?;
471        let (write, mut read) = stream.split();
472
473        let cancel = CancellationToken::new();
474        let loop_cancel = cancel.clone();
475        let on_response = self.on_send_response_message.clone();
476        tokio::spawn(async move {
477            loop {
478                tokio::select! {
479                    _ = loop_cancel.cancelled() => break,
480                    msg = read.next() => match msg {
481                        Some(Ok(Message::Text(text))) => {
482                            let message = CopilotWebSocketMessage::from_text(text);
483                            if let Some(out) = apply_transform(&on_response, message) {
484                                let _ = response.send_message(out).await;
485                            }
486                        }
487                        Some(Ok(Message::Binary(data))) => {
488                            let message = CopilotWebSocketMessage { data, binary: true };
489                            if let Some(out) = apply_transform(&on_response, message) {
490                                let _ = response.send_message(out).await;
491                            }
492                        }
493                        Some(Ok(Message::Close(_))) | None => break,
494                        Some(Ok(_)) => continue,
495                        Some(Err(e)) => {
496                            let _ = response.fail(e.to_string(), None).await;
497                            return;
498                        }
499                    }
500                }
501            }
502            let _ = response.close().await;
503        });
504
505        Ok(CopilotWebSocketForwarder {
506            write: AsyncMutex::new(Some(write)),
507            on_send_request_message: self.on_send_request_message,
508            cancel,
509        })
510    }
511}
512
513/// The default WebSocket handler: forwards each runtime message to the real
514/// upstream and each upstream message back to the runtime. Mutate by supplying
515/// transforms on the [builder](CopilotWebSocketForwarder::builder).
516pub struct CopilotWebSocketForwarder {
517    write: AsyncMutex<Option<UpstreamWrite>>,
518    on_send_request_message: Option<WebSocketTransform>,
519    cancel: CancellationToken,
520}
521
522impl CopilotWebSocketForwarder {
523    /// Start building a forwarding handler for `url` with the given upstream
524    /// handshake headers.
525    pub fn builder(url: String, headers: HeaderMap) -> CopilotWebSocketForwarderBuilder {
526        CopilotWebSocketForwarderBuilder {
527            url,
528            headers,
529            on_send_request_message: None,
530            on_send_response_message: None,
531        }
532    }
533}
534
535#[async_trait]
536impl CopilotWebSocketHandler for CopilotWebSocketForwarder {
537    async fn send_request_message(
538        &self,
539        message: CopilotWebSocketMessage,
540    ) -> Result<(), CopilotRequestError> {
541        let Some(message) = apply_transform(&self.on_send_request_message, message) else {
542            return Ok(());
543        };
544        let ws_message = if message.binary {
545            Message::Binary(message.data)
546        } else {
547            let text = match String::from_utf8(message.data) {
548                Ok(text) => text,
549                Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
550            };
551            Message::Text(text)
552        };
553        let mut guard = self.write.lock().await;
554        if let Some(write) = guard.as_mut() {
555            write
556                .send(ws_message)
557                .await
558                .map_err(|e| CopilotRequestError::Upstream(e.to_string()))?;
559        }
560        Ok(())
561    }
562
563    async fn close(&self) -> Result<(), CopilotRequestError> {
564        self.cancel.cancel();
565        let mut guard = self.write.lock().await;
566        if let Some(mut write) = guard.take() {
567            let _ = write.send(Message::Close(None)).await;
568            let _ = write.close().await;
569        }
570        Ok(())
571    }
572}
573
574fn apply_transform(
575    transform: &Option<WebSocketTransform>,
576    message: CopilotWebSocketMessage,
577) -> Option<CopilotWebSocketMessage> {
578    match transform {
579        Some(f) => f(message),
580        None => Some(message),
581    }
582}
583
584/// Mutable response state machine for a single exchange.
585#[derive(Default)]
586struct ResponseState {
587    started: bool,
588    finished: bool,
589}
590
591/// One intercepted request in flight.
592///
593/// Carries the request metadata plus the body byte stream the runtime feeds in
594/// via `httpRequestChunk` frames, and emits the handler's response straight back
595/// to the runtime through the generated `llmInference` server API — a single
596/// object the dispatcher owns and the handler drives.
597/// Request context populated when the matching `httpRequestStart` frame
598/// arrives. Held behind a `OnceLock` so the owning [`CopilotRequestExchange`]
599/// can be created bare by a body chunk that races ahead of its start frame.
600#[derive(Default)]
601struct RequestMeta {
602    session_id: Option<String>,
603    agent_id: Option<String>,
604    parent_agent_id: Option<String>,
605    interaction_type: Option<String>,
606    method: String,
607    url: String,
608    headers: HeaderMap,
609    transport: CopilotRequestTransport,
610}
611
612struct CopilotRequestExchange {
613    request_id: String,
614    meta: OnceLock<RequestMeta>,
615    cancel: CancellationToken,
616    client: Weak<ClientInner>,
617    /// Sender feeding the request body stream. Dropped (set to `None`) on `end`
618    /// or `cancel` to close the stream.
619    body_tx: Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>,
620    body_rx: AsyncMutex<mpsc::UnboundedReceiver<Vec<u8>>>,
621    state: Mutex<ResponseState>,
622}
623
624impl CopilotRequestExchange {
625    fn new(request_id: String, client: Weak<ClientInner>) -> Self {
626        let (body_tx, body_rx) = mpsc::unbounded_channel();
627        Self {
628            request_id,
629            meta: OnceLock::new(),
630            cancel: CancellationToken::new(),
631            client,
632            body_tx: Mutex::new(Some(body_tx)),
633            body_rx: AsyncMutex::new(body_rx),
634            state: Mutex::new(ResponseState::default()),
635        }
636    }
637
638    /// Fill in the request context once the matching start frame arrives.
639    fn set_context(&self, params: LlmInferenceHttpRequestStartRequest) {
640        let _ = self.meta.set(RequestMeta {
641            session_id: params.session_id.map(SessionId::into_inner),
642            agent_id: params.agent_id,
643            parent_agent_id: params.parent_agent_id,
644            interaction_type: params.interaction_type,
645            method: params.method,
646            url: params.url,
647            headers: headers_from_wire(&params.headers),
648            transport: CopilotRequestTransport::from_wire(params.transport),
649        });
650    }
651
652    /// Request metadata. Always populated before the handler runs; the
653    /// defaulted fallback only guards the (contract-impossible) case of a body
654    /// chunk with no preceding start frame.
655    fn meta(&self) -> &RequestMeta {
656        self.meta.get_or_init(RequestMeta::default)
657    }
658
659    fn context(&self) -> CopilotRequestContext {
660        let meta = self.meta();
661        CopilotRequestContext {
662            request_id: self.request_id.clone(),
663            session_id: meta.session_id.clone(),
664            agent_id: meta.agent_id.clone(),
665            parent_agent_id: meta.parent_agent_id.clone(),
666            interaction_type: meta.interaction_type.clone(),
667            transport: meta.transport,
668            url: meta.url.clone(),
669            headers: meta.headers.clone(),
670            cancel: self.cancel.clone(),
671        }
672    }
673
674    fn client(&self) -> Result<Client, CopilotRequestError> {
675        self.client
676            .upgrade()
677            .map(Client::from_inner)
678            .ok_or(CopilotRequestError::ConnectionClosed)
679    }
680
681    fn request_id(&self) -> RequestId {
682        RequestId::new(self.request_id.clone())
683    }
684
685    // --- Request body feed (driven by the dispatcher as frames arrive) ---
686
687    fn push_chunk(&self, data: Vec<u8>) {
688        if let Some(tx) = self.body_tx.lock().as_ref() {
689            let _ = tx.send(data);
690        }
691    }
692
693    fn push_end(&self) {
694        *self.body_tx.lock() = None;
695    }
696
697    fn push_cancel(&self) {
698        self.cancel.cancel();
699        *self.body_tx.lock() = None;
700    }
701
702    async fn recv_body(&self) -> Option<Vec<u8>> {
703        self.body_rx.lock().await.recv().await
704    }
705
706    async fn drain_body(&self) -> Vec<u8> {
707        let mut buf = Vec::new();
708        let mut rx = self.body_rx.lock().await;
709        while let Some(frame) = rx.recv().await {
710            buf.extend_from_slice(&frame);
711        }
712        buf
713    }
714
715    // --- Response emit (driven by the handler). Strict state machine: ---
716    // start_response once -> 0..N write -> exactly one of
717    // end_response / error_response.
718
719    fn started(&self) -> bool {
720        self.state.lock().started
721    }
722
723    fn finished(&self) -> bool {
724        self.state.lock().finished
725    }
726
727    async fn start_response(
728        &self,
729        status: u16,
730        status_text: Option<String>,
731        headers: HeaderMap,
732    ) -> Result<(), CopilotRequestError> {
733        {
734            let mut state = self.state.lock();
735            if state.started {
736                return Err(CopilotRequestError::InvalidState(
737                    "response start() called twice".to_string(),
738                ));
739            }
740            if state.finished {
741                return Err(CopilotRequestError::InvalidState(
742                    "response already finished".to_string(),
743                ));
744            }
745            state.started = true;
746        }
747        let request = LlmInferenceHttpResponseStartRequest {
748            headers: headers_to_wire(&headers),
749            request_id: self.request_id(),
750            status: i64::from(status),
751            status_text,
752        };
753        self.client()?
754            .rpc()
755            .llm_inference()
756            .http_response_start(request)
757            .await?;
758        Ok(())
759    }
760
761    /// Start the WebSocket upgrade head (status 101) once, ignoring repeat
762    /// calls. The dispatcher emits it eagerly before pumping; later writes call
763    /// this as a harmless no-op backstop.
764    async fn ensure_ws_started(&self) -> Result<(), CopilotRequestError> {
765        if self.started() {
766            return Ok(());
767        }
768        self.start_response(101, None, HeaderMap::new()).await
769    }
770
771    async fn write_text(&self, text: &str) -> Result<(), CopilotRequestError> {
772        self.write(text.to_string(), false).await
773    }
774
775    async fn write_binary(&self, data: &[u8]) -> Result<(), CopilotRequestError> {
776        let encoded = base64::engine::general_purpose::STANDARD.encode(data);
777        self.write(encoded, true).await
778    }
779
780    async fn write(&self, data: String, binary: bool) -> Result<(), CopilotRequestError> {
781        {
782            let state = self.state.lock();
783            if !state.started {
784                return Err(CopilotRequestError::InvalidState(
785                    "response write called before start()".to_string(),
786                ));
787            }
788            if state.finished {
789                return Err(CopilotRequestError::InvalidState(
790                    "response write called after end()/error()".to_string(),
791                ));
792            }
793        }
794        let request = LlmInferenceHttpResponseChunkRequest {
795            binary: binary.then_some(true),
796            data,
797            end: Some(false),
798            error: None,
799            request_id: self.request_id(),
800        };
801        self.client()?
802            .rpc()
803            .llm_inference()
804            .http_response_chunk(request)
805            .await?;
806        Ok(())
807    }
808
809    async fn end_response(&self) -> Result<(), CopilotRequestError> {
810        {
811            let mut state = self.state.lock();
812            if state.finished {
813                return Ok(());
814            }
815            state.finished = true;
816        }
817        let request = LlmInferenceHttpResponseChunkRequest {
818            binary: None,
819            data: String::new(),
820            end: Some(true),
821            error: None,
822            request_id: self.request_id(),
823        };
824        self.client()?
825            .rpc()
826            .llm_inference()
827            .http_response_chunk(request)
828            .await?;
829        Ok(())
830    }
831
832    async fn error_response(
833        &self,
834        message: impl Into<String>,
835        code: Option<String>,
836    ) -> Result<(), CopilotRequestError> {
837        {
838            let mut state = self.state.lock();
839            if state.finished {
840                return Ok(());
841            }
842            state.finished = true;
843        }
844        let request = LlmInferenceHttpResponseChunkRequest {
845            binary: None,
846            data: String::new(),
847            end: Some(true),
848            error: Some(LlmInferenceHttpResponseChunkError {
849                code,
850                message: message.into(),
851            }),
852            request_id: self.request_id(),
853        };
854        self.client()?
855            .rpc()
856            .llm_inference()
857            .http_response_chunk(request)
858            .await?;
859        Ok(())
860    }
861}
862
863/// Drive one exchange through the registered handler, dispatching by transport.
864async fn drive_exchange(
865    exchange: &Arc<CopilotRequestExchange>,
866    handler: &Arc<dyn CopilotRequestHandler>,
867) -> Result<(), CopilotRequestError> {
868    let ctx = exchange.context();
869    let meta = exchange.meta();
870    match meta.transport {
871        CopilotRequestTransport::Http => {
872            let body = exchange.drain_body().await;
873            let request = CopilotHttpRequest {
874                method: meta.method.clone(),
875                url: meta.url.clone(),
876                headers: meta.headers.clone(),
877                body,
878                cancel: ctx.cancel.clone(),
879            };
880            let response = handler.send_request(request, &ctx).await?;
881            stream_http_response(response, exchange, &ctx.cancel).await
882        }
883        CopilotRequestTransport::WebSocket => {
884            // The runtime blocks the WebSocket connect until it receives the 101
885            // response head (the upgrade acknowledgement) and only then forwards
886            // inbound messages as request-body chunks. Emit it eagerly here —
887            // waiting for the first upstream message would deadlock, since the
888            // upstream stays silent until it receives a request message the
889            // runtime won't send before the upgrade completes.
890            exchange.ensure_ws_started().await?;
891            let response = CopilotWebSocketResponse::new(exchange.clone());
892            let ws = handler.open_websocket(&ctx, response).await?;
893            let result = pump_websocket_requests(ws.as_ref(), exchange, &ctx.cancel).await;
894            let _ = ws.close().await;
895            match result {
896                Ok(()) => exchange.end_response().await,
897                Err(err) if ctx.cancel.is_cancelled() => {
898                    exchange
899                        .error_response(
900                            "Request cancelled by runtime",
901                            Some("cancelled".to_string()),
902                        )
903                        .await?;
904                    let _ = err;
905                    Ok(())
906                }
907                Err(err) => Err(err),
908            }
909        }
910    }
911}
912
913/// Stream an HTTP response into the runtime, honouring cancellation.
914async fn stream_http_response(
915    response: CopilotHttpResponse,
916    exchange: &CopilotRequestExchange,
917    cancel: &CancellationToken,
918) -> Result<(), CopilotRequestError> {
919    exchange
920        .start_response(response.status, response.status_text, response.headers)
921        .await?;
922
923    let mut body = response.body;
924    loop {
925        tokio::select! {
926            _ = cancel.cancelled() => {
927                return exchange
928                    .error_response("Request cancelled by runtime", Some("cancelled".to_string()))
929                    .await;
930            }
931            next = body.next() => match next {
932                Some(Ok(chunk)) => {
933                    for piece in chunk.chunks(32 * 1024) {
934                        exchange.write_binary(piece).await?;
935                    }
936                }
937                Some(Err(e)) => {
938                    return exchange.error_response(e.to_string(), None).await;
939                }
940                None => break,
941            }
942        }
943    }
944    exchange.end_response().await
945}
946
947/// Forward runtime→upstream WebSocket messages until the runtime closes its side
948/// or cancels.
949async fn pump_websocket_requests(
950    handler: &dyn CopilotWebSocketHandler,
951    exchange: &CopilotRequestExchange,
952    cancel: &CancellationToken,
953) -> Result<(), CopilotRequestError> {
954    loop {
955        tokio::select! {
956            _ = cancel.cancelled() => {
957                return Err(CopilotRequestError::message("Request cancelled by runtime"));
958            }
959            frame = exchange.recv_body() => match frame {
960                Some(data) => {
961                    handler
962                        .send_request_message(CopilotWebSocketMessage { data, binary: false })
963                        .await?;
964                }
965                None => return Ok(()),
966            }
967        }
968    }
969}
970
971/// Drive the exchange's response to a terminal state once the handler returns,
972/// covering handlers that error, get cancelled, or forget to finalize.
973async fn finalize_exchange(
974    exchange: &CopilotRequestExchange,
975    result: Result<(), CopilotRequestError>,
976) {
977    match result {
978        Ok(()) => {
979            if !exchange.finished() {
980                fail_via_response(
981                    exchange,
982                    502,
983                    "Copilot request handler returned without finalising the response".to_string(),
984                )
985                .await;
986            }
987        }
988        Err(err) => {
989            if exchange.finished() {
990                return;
991            }
992            if exchange.cancel.is_cancelled() {
993                if !exchange.started() {
994                    let _ = exchange.start_response(499, None, HeaderMap::new()).await;
995                }
996                let _ = exchange
997                    .error_response(
998                        "Request cancelled by runtime",
999                        Some("cancelled".to_string()),
1000                    )
1001                    .await;
1002            } else {
1003                fail_via_response(exchange, 502, err.to_string()).await;
1004            }
1005        }
1006    }
1007}
1008
1009async fn fail_via_response(exchange: &CopilotRequestExchange, status: u16, message: String) {
1010    if !exchange.started() {
1011        let _ = exchange
1012            .start_response(status, None, HeaderMap::new())
1013            .await;
1014    }
1015    let _ = exchange.error_response(message, None).await;
1016}
1017
1018/// Routes inbound `llmInference.*` requests to the registered handler,
1019/// reassembling each request's streaming body and acking every frame.
1020pub(crate) struct CopilotRequestDispatcher {
1021    handler: Arc<dyn CopilotRequestHandler>,
1022    client: OnceLock<Weak<ClientInner>>,
1023    pending: Mutex<HashMap<String, Arc<CopilotRequestExchange>>>,
1024}
1025
1026impl CopilotRequestDispatcher {
1027    pub(crate) fn new(handler: Arc<dyn CopilotRequestHandler>) -> Self {
1028        Self {
1029            handler,
1030            client: OnceLock::new(),
1031            pending: Mutex::new(HashMap::new()),
1032        }
1033    }
1034
1035    pub(crate) fn set_client(&self, client: Weak<ClientInner>) {
1036        let _ = self.client.set(client);
1037    }
1038
1039    fn client(&self) -> Option<Client> {
1040        self.client
1041            .get()
1042            .and_then(Weak::upgrade)
1043            .map(Client::from_inner)
1044    }
1045
1046    fn client_weak(&self) -> Weak<ClientInner> {
1047        self.client.get().cloned().unwrap_or_else(Weak::new)
1048    }
1049
1050    pub(crate) async fn dispatch(self: &Arc<Self>, request: JsonRpcRequest) {
1051        match request.method.as_str() {
1052            METHOD_HTTP_REQUEST_START => self.handle_start(request).await,
1053            METHOD_HTTP_REQUEST_CHUNK => self.handle_chunk(request).await,
1054            other => {
1055                warn!(method = other, "unknown llmInference request method");
1056                self.send_error(request.id, "unknown llmInference method")
1057                    .await;
1058            }
1059        }
1060    }
1061
1062    fn get_or_create_exchange(&self, request_id: String) -> Arc<CopilotRequestExchange> {
1063        // The runtime dispatches httpRequestStart and httpRequestChunk frames
1064        // independently. get-or-create keeps the adapter correct regardless of
1065        // arrival order: a body chunk (including the terminal end frame) that
1066        // races ahead of its start frame is buffered into the same exchange
1067        // rather than dropped, which would otherwise hang the body drain.
1068        self.pending
1069            .lock()
1070            .entry(request_id.clone())
1071            .or_insert_with(|| {
1072                Arc::new(CopilotRequestExchange::new(request_id, self.client_weak()))
1073            })
1074            .clone()
1075    }
1076
1077    async fn handle_start(self: &Arc<Self>, request: JsonRpcRequest) {
1078        let id = request.id;
1079        let Some(params) = parse_params::<LlmInferenceHttpRequestStartRequest>(&request) else {
1080            self.send_error(id, "invalid llmInference.httpRequestStart params")
1081                .await;
1082            return;
1083        };
1084
1085        // Adopt any exchange a racing chunk already created — with its buffered
1086        // body — rather than dropping those frames.
1087        let request_id = params.request_id.clone().into_inner();
1088        let exchange = self.get_or_create_exchange(request_id.clone());
1089        exchange.set_context(params);
1090
1091        let handler = self.handler.clone();
1092        let dispatcher = Arc::clone(self);
1093        let exchange_for_task = exchange.clone();
1094        tokio::spawn(async move {
1095            let result = drive_exchange(&exchange_for_task, &handler).await;
1096            finalize_exchange(&exchange_for_task, result).await;
1097            dispatcher.remove_pending(&request_id);
1098        });
1099
1100        self.ack(id).await;
1101    }
1102
1103    async fn handle_chunk(&self, request: JsonRpcRequest) {
1104        let id = request.id;
1105        let Some(params) = parse_params::<LlmInferenceHttpRequestChunkRequest>(&request) else {
1106            self.send_error(id, "invalid llmInference.httpRequestChunk params")
1107                .await;
1108            return;
1109        };
1110
1111        // May arrive before the matching start frame; get-or-create so the body
1112        // is buffered, never lost.
1113        let exchange = self.get_or_create_exchange(params.request_id.to_string());
1114        apply_chunk(&exchange, &params);
1115
1116        self.ack(id).await;
1117    }
1118
1119    fn remove_pending(&self, request_id: &str) {
1120        self.pending.lock().remove(request_id);
1121    }
1122
1123    async fn ack(&self, id: u64) {
1124        let Some(client) = self.client() else {
1125            return;
1126        };
1127        let _ = client
1128            .send_response(&JsonRpcResponse {
1129                jsonrpc: "2.0".to_string(),
1130                id,
1131                result: Some(serde_json::json!({})),
1132                error: None,
1133            })
1134            .await;
1135    }
1136
1137    async fn send_error(&self, id: u64, message: &str) {
1138        let Some(client) = self.client() else {
1139            return;
1140        };
1141        let _ = client
1142            .send_response(&JsonRpcResponse {
1143                jsonrpc: "2.0".to_string(),
1144                id,
1145                result: None,
1146                error: Some(crate::JsonRpcError {
1147                    code: error_codes::INTERNAL_ERROR,
1148                    message: message.to_string(),
1149                    data: None,
1150                }),
1151            })
1152            .await;
1153    }
1154}
1155
1156/// Apply one body chunk to a pending request: route data into the body stream,
1157/// or terminate it on `end` / `cancel`.
1158fn apply_chunk(exchange: &CopilotRequestExchange, params: &LlmInferenceHttpRequestChunkRequest) {
1159    if params.cancel == Some(true) {
1160        exchange.push_cancel();
1161        return;
1162    }
1163
1164    if !params.data.is_empty() {
1165        let decoded = if params.binary == Some(true) {
1166            match base64::engine::general_purpose::STANDARD.decode(params.data.as_bytes()) {
1167                Ok(bytes) => bytes,
1168                Err(e) => {
1169                    warn!(error = %e, "failed to decode base64 llmInference body chunk");
1170                    return;
1171                }
1172            }
1173        } else {
1174            params.data.clone().into_bytes()
1175        };
1176        exchange.push_chunk(decoded);
1177    }
1178
1179    if params.end == Some(true) {
1180        exchange.push_end();
1181    }
1182}
1183
1184fn parse_params<T: serde::de::DeserializeOwned>(request: &JsonRpcRequest) -> Option<T> {
1185    request
1186        .params
1187        .as_ref()
1188        .and_then(|p| serde_json::from_value(p.clone()).ok())
1189}
1190
1191/// Convert a wire header map into an [`http::HeaderMap`], skipping any entry the
1192/// `http` crate rejects.
1193fn headers_from_wire(wire: &HashMap<String, Vec<String>>) -> HeaderMap {
1194    let mut headers = HeaderMap::new();
1195    for (name, values) in wire {
1196        let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
1197            continue;
1198        };
1199        for value in values {
1200            let Ok(header_value) = HeaderValue::from_str(value) else {
1201                continue;
1202            };
1203            headers.append(header_name.clone(), header_value);
1204        }
1205    }
1206    headers
1207}
1208
1209/// Convert an [`http::HeaderMap`] into the wire header map, dropping values that
1210/// are not valid UTF-8.
1211fn headers_to_wire(headers: &HeaderMap) -> HashMap<String, Vec<String>> {
1212    let mut wire: HashMap<String, Vec<String>> = HashMap::new();
1213    for (name, value) in headers {
1214        let Ok(value) = value.to_str() else {
1215            continue;
1216        };
1217        wire.entry(name.as_str().to_string())
1218            .or_default()
1219            .push(value.to_string());
1220    }
1221    wire
1222}