Skip to main content

nexus_core/host/
api.rs

1//! The raw `tokio` HTTP/SSE server for `nexus host`.
2//!
3//! This deliberately stays framework-free. The daemon is local-first, the
4//! route set is small, and using one parser makes the authentication and body
5//! limits easy to audit. The app itself lives in [`AppActor`]; connections
6//! never hold a lock across `App::next_event().await`.
7
8use std::collections::HashMap;
9use std::io;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use std::time::Duration;
13
14use anyhow::{Context as _, Result, anyhow, bail};
15use futures_util::StreamExt;
16use serde::{Deserialize, Serialize};
17use tokio::io::{AsyncReadExt, AsyncWriteExt};
18use tokio::net::{TcpListener, TcpStream};
19use tokio::sync::{Notify, broadcast, mpsc, oneshot};
20use tokio::task::JoinHandle;
21
22use crate::app::{App, AppCommand, AppEvent, CoreSnapshot};
23use crate::appserver::AppRegistry;
24use crate::provider::BackendTag;
25use crate::provider::openrouter;
26use crate::sync::Changeset;
27use crate::tools::ToolExecutor;
28
29use super::wire::{WireBackendTag, WireEvent, WireModel, public_model_id};
30
31const MAX_HEADER_BYTES: usize = 32 * 1024;
32const MAX_API_BODY_BYTES: usize = 10 * 1024 * 1024;
33const MAX_GATEWAY_BODY_BYTES: usize = 64 * 1024 * 1024;
34const MAX_BLOB_BYTES: usize = 64 * 1024 * 1024;
35/// How much of a request body is read (and reserved) at a time.
36const BODY_CHUNK_BYTES: usize = 64 * 1024;
37/// Ceiling on the bytes held back while looking for a provider usage frame.
38const MAX_USAGE_BUFFER_BYTES: usize = 8 * 1024 * 1024;
39const SSE_HEARTBEAT: Duration = Duration::from_secs(15);
40const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(15);
41const ACTOR_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
42const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
43const UPSTREAM_HEADERS_TIMEOUT: Duration = Duration::from_secs(20);
44const UPSTREAM_CHUNK_TIMEOUT: Duration = Duration::from_mins(2);
45const MAX_APP_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
46const MAX_CONNECTIONS: usize = 128;
47/// CORS block shared by every response, including the `OPTIONS` preflight.
48/// `Access-Control-Allow-Methods` is required: without it a browser rejects
49/// the preflight for `POST /v1/command` and `POST /v1/chat/completions`
50/// (JSON bodies are never "simple" requests).
51const CORS_HEADERS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, HEAD, OPTIONS\r\nAccess-Control-Allow-Headers: Authorization, Content-Type, X-Nexus-Backend, X-Nexus-Blob-Hash, X-Content-SHA256\r\nAccess-Control-Max-Age: 600\r\n";
52
53/// Configuration for a local host listener.
54#[derive(Debug, Clone)]
55pub struct HostConfig {
56    /// TCP port on loopback. `0` asks the OS for an ephemeral port (useful in
57    /// tests); the CLI defaults this to `8643`.
58    pub port: u16,
59    /// Bearer token required by `/v1/*`; registered public app UUID routes do
60    /// not embed or issue this token.
61    pub token: String,
62    /// Optional upstream override used by hermetic gateway tests. Production
63    /// callers leave this `None`, selecting the provider's canonical base.
64    gateway_base: Option<String>,
65}
66
67impl HostConfig {
68    /// Construct a listener configuration.
69    pub fn new(port: u16, token: impl Into<String>) -> Self {
70        Self {
71            port,
72            token: token.into(),
73            gateway_base: None,
74        }
75    }
76
77    /// Route all gateway flavors to a local mock upstream. Intended for
78    /// network-free integration tests, not normal hosting.
79    #[must_use]
80    pub fn with_gateway_base(mut self, base: impl Into<String>) -> Self {
81        self.gateway_base = Some(base.into());
82        self
83    }
84}
85
86/// A backend route selected for one gateway request. The API handler keeps
87/// this object private so an upstream key can never be serialized or returned
88/// in an error response.
89#[derive(Clone)]
90struct GatewayRoute {
91    tag: BackendTag,
92    model: String,
93    key: String,
94    account_id: Option<String>,
95}
96
97/// A usage fragment observed while forwarding one provider response.
98#[derive(Debug, Default)]
99struct GatewayUsage {
100    prompt_tokens: u64,
101    completion_tokens: u64,
102    cache_read_tokens: u64,
103    cache_creation_tokens: u64,
104    cost: Option<f64>,
105    /// Bytes not yet forming a complete `SSE` line (or the whole body of a
106    /// non-streaming response). Chunk boundaries fall wherever the socket
107    /// puts them, so parsing each chunk in isolation would drop the usage
108    /// frame whenever it straddles two of them.
109    buffer: Vec<u8>,
110}
111
112impl GatewayUsage {
113    fn observe(&mut self, bytes: &[u8], streaming: bool) {
114        if self.buffer.len() + bytes.len() > MAX_USAGE_BUFFER_BYTES {
115            return;
116        }
117        self.buffer.extend_from_slice(bytes);
118        if !streaming {
119            // One JSON document; it can only be parsed once it is complete.
120            return;
121        }
122        while let Some(position) = self.buffer.iter().position(|byte| *byte == b'\n') {
123            let line: Vec<u8> = self.buffer.drain(..=position).collect();
124            self.observe_line(&String::from_utf8_lossy(&line));
125        }
126    }
127
128    /// Parse whatever is left once the upstream body ends.
129    fn finish(&mut self, streaming: bool) {
130        let rest = std::mem::take(&mut self.buffer);
131        if rest.is_empty() {
132            return;
133        }
134        let text = String::from_utf8_lossy(&rest).into_owned();
135        if streaming {
136            self.observe_line(&text);
137        } else {
138            self.observe_json(text.trim());
139        }
140    }
141
142    fn observe_line(&mut self, line: &str) {
143        let Some(data) = line.trim_end().strip_prefix("data:") else {
144            return;
145        };
146        let data = data.trim();
147        if data != "[DONE]" {
148            self.observe_json(data);
149        }
150    }
151
152    fn observe_json(&mut self, text: &str) {
153        let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
154            return;
155        };
156        let Some(usage) = value.get("usage").and_then(serde_json::Value::as_object) else {
157            return;
158        };
159        let number = |key: &str| {
160            usage
161                .get(key)
162                .and_then(serde_json::Value::as_u64)
163                .unwrap_or(0)
164        };
165        let nested = |group: &str, key: &str| {
166            usage
167                .get(group)
168                .and_then(|value| value.get(key))
169                .and_then(serde_json::Value::as_u64)
170                .unwrap_or(0)
171        };
172        self.prompt_tokens = self.prompt_tokens.max(number("prompt_tokens"));
173        self.completion_tokens = self.completion_tokens.max(number("completion_tokens"));
174        self.cache_read_tokens = self.cache_read_tokens.max(
175            nested("prompt_tokens_details", "cached_tokens")
176                .max(nested("input_tokens_details", "cached_tokens"))
177                .max(number("cache_read_input_tokens")),
178        );
179        self.cache_creation_tokens = self.cache_creation_tokens.max(
180            nested("prompt_tokens_details", "cache_write_tokens")
181                .max(nested("input_tokens_details", "cache_write_tokens"))
182                .max(number("cache_creation_input_tokens")),
183        );
184        self.cost = usage.get("cost").and_then(json_f64).or(self.cost);
185    }
186}
187
188fn json_f64(value: &serde_json::Value) -> Option<f64> {
189    value
190        .as_f64()
191        .or_else(|| value.as_str()?.parse::<f64>().ok())
192        .filter(|number| number.is_finite())
193}
194
195/// A running host server. It owns the listener and app actor; dropping it
196/// aborts both background tasks, while [`Self::shutdown`] gives them a clean
197/// cancellation opportunity first.
198pub struct HostServer {
199    state: Arc<HostState>,
200    shutdown: Arc<Notify>,
201    accept_task: Option<JoinHandle<()>>,
202    actor_task: Option<JoinHandle<()>>,
203    addr: SocketAddr,
204}
205
206#[derive(Clone)]
207struct HostState {
208    requests: mpsc::Sender<HostRequest>,
209    events: broadcast::Sender<WireEvent>,
210    token: Arc<String>,
211    app_server_port: Option<u16>,
212    app_registry: Option<AppRegistry>,
213    client: reqwest::Client,
214    connections: Arc<tokio::sync::Semaphore>,
215    gateway_base: Option<String>,
216}
217
218impl HostServer {
219    /// Bind a loopback host listener and start the app actor.
220    pub async fn bind(app: App, config: HostConfig) -> Result<Self> {
221        if config.token.trim().is_empty() {
222            bail!("host token must not be empty");
223        }
224        let listener = TcpListener::bind(("127.0.0.1", config.port))
225            .await
226            .with_context(|| format!("binding host listener on 127.0.0.1:{}", config.port))?;
227        let addr = listener
228            .local_addr()
229            .context("reading host listener address")?;
230        let app_server_port = app
231            .app_server
232            .as_ref()
233            .map(crate::appserver::AppServer::port);
234        let app_registry = app
235            .app_server
236            .as_ref()
237            .map(|server| server.registry().clone());
238        let (request_tx, request_rx) = mpsc::channel(64);
239        let (events, _) = broadcast::channel(256);
240        let shutdown = Arc::new(Notify::new());
241        let host_token = Arc::new(config.token);
242        let state = Arc::new(HostState {
243            requests: request_tx,
244            events: events.clone(),
245            token: host_token.clone(),
246            app_server_port,
247            app_registry,
248            client: reqwest::Client::builder()
249                .connect_timeout(UPSTREAM_CONNECT_TIMEOUT)
250                .pool_max_idle_per_host(8)
251                .pool_idle_timeout(Duration::from_secs(30))
252                .build()
253                .context("building host upstream client")?,
254            connections: Arc::new(tokio::sync::Semaphore::new(MAX_CONNECTIONS)),
255            gateway_base: config.gateway_base,
256        });
257        let actor_events = events;
258        let actor_shutdown = shutdown.clone();
259        let actor_task = tokio::spawn(async move {
260            app_actor(app, request_rx, actor_events, actor_shutdown, host_token).await;
261        });
262        let accept_state = state.clone();
263        let accept_shutdown = shutdown.clone();
264        let accept_task = tokio::spawn(async move {
265            accept_loop(listener, accept_state, accept_shutdown).await;
266        });
267        Ok(Self {
268            state,
269            shutdown,
270            accept_task: Some(accept_task),
271            actor_task: Some(actor_task),
272            addr,
273        })
274    }
275
276    /// The loopback address selected by the OS.
277    pub const fn local_addr(&self) -> SocketAddr {
278        self.addr
279    }
280
281    /// Subscribe to the global app event stream. Each subscriber receives
282    /// events emitted after it subscribes; slow subscribers may be told they
283    /// lagged and should refresh from `/v1/snapshot`.
284    pub fn subscribe(&self) -> broadcast::Receiver<WireEvent> {
285        self.state.events.subscribe()
286    }
287
288    /// Set or clear the public app-link base used by the app actor.
289    pub async fn set_public_base(&self, base: Option<String>) -> Result<()> {
290        let (reply, rx) = oneshot::channel();
291        self.state
292            .requests
293            .send(HostRequest::SetPublicBase { base, reply })
294            .await
295            .map_err(|_| anyhow!("host actor stopped"))?;
296        let result = rx.await.map_err(|_| anyhow!("host actor stopped"))?;
297        result.map_err(|error| anyhow!(error))?;
298        Ok(())
299    }
300
301    /// Stop accepting connections and abort the app actor.
302    pub async fn shutdown(&mut self) {
303        self.shutdown.notify_waiters();
304        if let Some(task) = self.accept_task.take() {
305            task.abort();
306            let _ = task.await;
307        }
308        if let Some(task) = self.actor_task.take() {
309            task.abort();
310            let _ = task.await;
311        }
312    }
313}
314
315impl Drop for HostServer {
316    fn drop(&mut self) {
317        self.shutdown.notify_waiters();
318        if let Some(task) = self.accept_task.take() {
319            task.abort();
320        }
321        if let Some(task) = self.actor_task.take() {
322            task.abort();
323        }
324    }
325}
326
327async fn accept_loop(listener: TcpListener, state: Arc<HostState>, shutdown: Arc<Notify>) {
328    loop {
329        tokio::select! {
330            () = shutdown.notified() => break,
331            accepted = listener.accept() => {
332                let Ok((stream, _peer)) = accepted else { continue; };
333                let Ok(permit) = state.connections.clone().try_acquire_owned() else {
334                    let mut stream = stream;
335                    let _ = respond_text(&mut stream, 503, "host connection limit reached").await;
336                    continue;
337                };
338                let state = state.clone();
339                tokio::spawn(async move {
340                    let _permit = permit;
341                    let _ = handle_connection(stream, state).await;
342                });
343            }
344        }
345    }
346}
347
348#[derive(Debug)]
349struct Request {
350    method: String,
351    target: String,
352    headers: HashMap<String, String>,
353    body: Vec<u8>,
354}
355
356#[derive(Debug)]
357struct ReadError {
358    status: u16,
359    message: &'static str,
360}
361
362async fn read_request(stream: &mut TcpStream) -> Result<Request, ReadError> {
363    let mut buffer = Vec::with_capacity(4096);
364    let mut chunk = [0u8; 4096];
365    let header_end = loop {
366        if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
367            break position;
368        }
369        let remaining = MAX_HEADER_BYTES.saturating_sub(buffer.len());
370        if remaining == 0 {
371            return Err(ReadError {
372                status: 431,
373                message: "request headers too large",
374            });
375        }
376        let read_len = remaining.min(chunk.len());
377        let n = stream
378            .read(&mut chunk[..read_len])
379            .await
380            .map_err(|_| ReadError {
381                status: 400,
382                message: "could not read request",
383            })?;
384        if n == 0 {
385            return Err(ReadError {
386                status: 400,
387                message: "incomplete request",
388            });
389        }
390        buffer.extend_from_slice(&chunk[..n]);
391    };
392    let header_text = std::str::from_utf8(&buffer[..header_end]).map_err(|_| ReadError {
393        status: 400,
394        message: "request headers are not UTF-8",
395    })?;
396    let mut lines = header_text.lines();
397    let request_line = lines.next().ok_or(ReadError {
398        status: 400,
399        message: "missing request line",
400    })?;
401    let mut parts = request_line.split_whitespace();
402    let method = parts.next().unwrap_or_default().to_string();
403    let target = parts.next().unwrap_or_default().to_string();
404    if method.is_empty() || target.is_empty() {
405        return Err(ReadError {
406            status: 400,
407            message: "bad request line",
408        });
409    }
410    let mut headers = HashMap::new();
411    for line in lines {
412        let Some((name, value)) = line.split_once(':') else {
413            return Err(ReadError {
414                status: 400,
415                message: "bad request header",
416            });
417        };
418        headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
419    }
420    let content_length = headers
421        .get("content-length")
422        .map(|value| {
423            value.parse::<usize>().map_err(|_| ReadError {
424                status: 400,
425                message: "invalid content length",
426            })
427        })
428        .transpose()?
429        .unwrap_or(0);
430    if content_length > MAX_GATEWAY_BODY_BYTES {
431        return Err(ReadError {
432            status: 413,
433            message: "request entity too large",
434        });
435    }
436    if headers
437        .get("transfer-encoding")
438        .is_some_and(|value| !value.eq_ignore_ascii_case("identity"))
439    {
440        return Err(ReadError {
441            status: 501,
442            message: "chunked request bodies are not supported",
443        });
444    }
445    let body_start = header_end + 4;
446    let already = buffer.len().saturating_sub(body_start).min(content_length);
447    let prefix = &buffer[body_start..body_start + already];
448    let body = read_body(stream, prefix, content_length).await?;
449    Ok(Request {
450        method,
451        target,
452        headers,
453        body,
454    })
455}
456
457/// Read `content_length` body bytes, `prefix` already having arrived with the
458/// headers. The buffer grows with the bytes that actually turn up rather than
459/// reserving the declared length up front: authentication happens after this
460/// read, so one unauthenticated request announcing 64 MB must not be able to
461/// allocate 64 MB without sending it.
462async fn read_body(
463    stream: &mut TcpStream,
464    prefix: &[u8],
465    content_length: usize,
466) -> Result<Vec<u8>, ReadError> {
467    let incomplete = ReadError {
468        status: 400,
469        message: "incomplete request body",
470    };
471    let mut body = Vec::with_capacity(content_length.min(BODY_CHUNK_BYTES).max(prefix.len()));
472    body.extend_from_slice(prefix);
473    while body.len() < content_length {
474        let start = body.len();
475        let want = (content_length - start).min(BODY_CHUNK_BYTES);
476        body.resize(start + want, 0);
477        let n = stream
478            .read(&mut body[start..])
479            .await
480            .map_err(|_| ReadError {
481                status: incomplete.status,
482                message: incomplete.message,
483            })?;
484        body.truncate(start + n);
485        if n == 0 {
486            return Err(incomplete);
487        }
488    }
489    Ok(body)
490}
491
492async fn handle_connection(mut stream: TcpStream, state: Arc<HostState>) -> io::Result<()> {
493    let request = match tokio::time::timeout(REQUEST_READ_TIMEOUT, read_request(&mut stream)).await
494    {
495        Ok(Ok(request)) => request,
496        Ok(Err(error)) => return respond_text(&mut stream, error.status, error.message).await,
497        Err(_) => return respond_text(&mut stream, 408, "request timed out").await,
498    };
499    let (path, query) = split_target(&request.target);
500    if request.method.eq_ignore_ascii_case("OPTIONS") {
501        return respond_empty(&mut stream, 204).await;
502    }
503    let is_apps = path == "/apps" || path.starts_with("/apps/");
504    let is_v1 = path == "/v1" || path.starts_with("/v1/");
505    if (is_apps || is_v1)
506        && !authorized(
507            &request,
508            &state.token,
509            is_apps,
510            state.app_registry.as_ref(),
511            path,
512        )
513    {
514        return respond_json(
515            &mut stream,
516            401,
517            &serde_json::json!({
518                "error": { "message": "unauthorized", "type": "authentication_error" }
519            }),
520        )
521        .await;
522    }
523
524    if path == "/v1/events" {
525        return handle_sse(&mut stream, &request, &state).await;
526    }
527    if path == "/v1/chat/completions" {
528        return handle_gateway(&mut stream, &request, &state).await;
529    }
530    if is_apps {
531        // Public app URLs are capabilities in their own right: the registry
532        // UUID is high entropy, while the host bearer token never appears in
533        // the URL, cookie jar, referrer, or proxy logs.
534        return proxy_app(&mut stream, &request, path, query, &state).await;
535    }
536    handle_api_route(&mut stream, &request, path, &state).await
537}
538
539fn authorized(
540    request: &Request,
541    token: &str,
542    apps: bool,
543    registry: Option<&AppRegistry>,
544    path: &str,
545) -> bool {
546    if let Some(value) = request.headers.get("authorization")
547        && let Some((scheme, supplied)) = value.split_once(char::is_whitespace)
548        && scheme.eq_ignore_ascii_case("bearer")
549        && constant_time_eq(supplied.trim().as_bytes(), token.as_bytes())
550    {
551        return true;
552    }
553    apps && known_app_path(registry, path)
554}
555
556fn known_app_path(registry: Option<&AppRegistry>, path: &str) -> bool {
557    let Some(registry) = registry else {
558        return false;
559    };
560    let Some(rest) = path.strip_prefix("/apps/") else {
561        return false;
562    };
563    let Some(uuid) = rest.split('/').next() else {
564        return false;
565    };
566    !uuid.is_empty() && registry.lookup(uuid).is_some()
567}
568
569/// Constant-time equality for the host token. The length difference is folded
570/// into the accumulator instead of returning early.
571fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
572    let mut difference = left.len() ^ right.len();
573    let length = left.len().max(right.len());
574    for index in 0..length {
575        let a = left.get(index).copied().unwrap_or(0);
576        let b = right.get(index).copied().unwrap_or(0);
577        difference |= usize::from(a ^ b);
578    }
579    difference == 0
580}
581
582#[allow(clippy::too_many_lines)]
583async fn handle_api_route(
584    stream: &mut TcpStream,
585    request: &Request,
586    path: &str,
587    state: &HostState,
588) -> io::Result<()> {
589    if path == "/v1/sync/blob"
590        || path.starts_with("/v1/sync/blob/")
591        || path.starts_with("/v1/sync/blobs/")
592    {
593        return handle_sync_blob(stream, request, path, state).await;
594    }
595    match (request.method.as_str(), path) {
596        ("GET", "/v1/snapshot") => {
597            let snapshot =
598                ask_actor(&state.requests, |reply| HostRequest::Snapshot { reply }).await;
599            match snapshot {
600                Ok(snapshot) => respond_json(stream, 200, &snapshot).await,
601                Err(error) => respond_error(stream, 500, &error).await,
602            }
603        }
604        ("GET", "/v1/models") => {
605            let models = ask_actor(&state.requests, |reply| HostRequest::Models { reply }).await;
606            match models {
607                Ok(models) => {
608                    let response = ModelsResponse {
609                        object: "list",
610                        data: models.into_iter().map(OpenAiModel::from).collect(),
611                    };
612                    respond_json(stream, 200, &response).await
613                }
614                Err(error) => respond_error(stream, 500, &error).await,
615            }
616        }
617        ("GET", "/v1/backends") => {
618            let backends =
619                ask_actor(&state.requests, |reply| HostRequest::Backends { reply }).await;
620            match backends {
621                Ok(backends) => respond_json(stream, 200, &BackendsResponse { backends }).await,
622                Err(error) => respond_error(stream, 500, &error).await,
623            }
624        }
625        ("POST", "/v1/command") => {
626            if request.body.len() > MAX_API_BODY_BYTES {
627                return respond_text(stream, 413, "request entity too large").await;
628            }
629            let command = match serde_json::from_slice::<AppCommand>(&request.body) {
630                Ok(command) => command,
631                Err(error) => {
632                    return respond_error(stream, 400, &format!("invalid command: {error}")).await;
633                }
634            };
635            let result = ask_actor(&state.requests, |reply| HostRequest::Command {
636                command,
637                reply,
638            })
639            .await;
640            match result {
641                Ok(()) => respond_json(stream, 202, &serde_json::json!({ "ok": true })).await,
642                Err(error) => respond_error(stream, 400, &error).await,
643            }
644        }
645        ("POST", "/v1/sync") => {
646            if request.body.len() > MAX_API_BODY_BYTES {
647                return respond_text(stream, 413, "request entity too large").await;
648            }
649            let changeset = match serde_json::from_slice::<Changeset>(&request.body) {
650                Ok(changeset) => changeset,
651                Err(error) => {
652                    return respond_error(stream, 400, &format!("invalid changeset: {error}"))
653                        .await;
654                }
655            };
656            let result = ask_actor(&state.requests, |reply| HostRequest::Sync {
657                changeset,
658                reply,
659            })
660            .await;
661            match result {
662                Ok(reply) => respond_json(stream, 200, &reply).await,
663                Err(error) => respond_error(stream, 400, &error).await,
664            }
665        }
666        ("GET", "/v1/tools") => {
667            let defs = ask_actor(&state.requests, |reply| HostRequest::ToolDefs { reply }).await;
668            match defs {
669                Ok(defs) => respond_json(stream, 200, &serde_json::json!({ "tools": defs })).await,
670                Err(error) => respond_error(stream, 500, &error).await,
671            }
672        }
673        ("POST", "/v1/tools/run") => {
674            if request.body.len() > MAX_API_BODY_BYTES {
675                return respond_text(stream, 413, "request entity too large").await;
676            }
677            let input = match serde_json::from_slice::<ToolRunRequest>(&request.body) {
678                Ok(input) => input,
679                Err(error) => {
680                    return respond_error(stream, 400, &format!("invalid tool request: {error}"))
681                        .await;
682                }
683            };
684            let toolbox =
685                match ask_actor(&state.requests, |reply| HostRequest::Toolbox { reply }).await {
686                    Ok(toolbox) => toolbox,
687                    Err(error) => return respond_error(stream, 500, &error).await,
688                };
689            let args = match input.args {
690                serde_json::Value::String(args) => args,
691                value => value.to_string(),
692            };
693            let (result, label) = toolbox.run(&input.name, &args).await;
694            respond_json(stream, 200, &ToolRunResponse { result, label }).await
695        }
696        ("GET", "/") => respond_text(stream, 200, "nexus host\n").await,
697        _ => respond_text(stream, 404, "not found").await,
698    }
699}
700
701#[derive(Debug, Serialize)]
702struct ModelsResponse {
703    object: &'static str,
704    data: Vec<OpenAiModel>,
705}
706
707/// `OpenAI` `/v1/models` fields, with Nexus routing metadata retained as
708/// additive fields for clients that want backend-aware pickers.
709#[derive(Debug, Serialize)]
710struct OpenAiModel {
711    id: String,
712    object: &'static str,
713    created: u64,
714    owned_by: String,
715    backend: WireBackendTag,
716    name: String,
717    reasoning_efforts: Vec<super::wire::WireReasoningEffort>,
718    context_length: Option<u64>,
719    supports_images: bool,
720    supports_image_generation: bool,
721    supports_video_generation: bool,
722    pricing: Option<super::wire::WireModelPricing>,
723}
724
725impl From<WireModel> for OpenAiModel {
726    fn from(model: WireModel) -> Self {
727        let owned_by = match model.backend {
728            WireBackendTag::OpenRouter => "openrouter",
729            WireBackendTag::OpenAi => "openai",
730            WireBackendTag::OpencodeGo => "opencode-go",
731            WireBackendTag::Codex => "codex",
732        };
733        Self {
734            id: model.id,
735            object: "model",
736            created: 0,
737            owned_by: owned_by.to_string(),
738            backend: model.backend,
739            name: model.name,
740            reasoning_efforts: model.reasoning_efforts,
741            context_length: model.context_length,
742            supports_images: model.supports_images,
743            supports_image_generation: model.supports_image_generation,
744            supports_video_generation: model.supports_video_generation,
745            pricing: model.pricing,
746        }
747    }
748}
749
750#[derive(Debug, Serialize)]
751struct BackendsResponse {
752    backends: Vec<BackendInfo>,
753}
754
755#[derive(Debug, Serialize)]
756struct BackendInfo {
757    tag: WireBackendTag,
758    name: &'static str,
759    configured: bool,
760    /// Whether `/v1/chat/completions` can route this backend.
761    gateway_supported: bool,
762    /// Stable explanation when a configured backend is intentionally omitted
763    /// from the gateway model picker.
764    gateway_error: Option<&'static str>,
765    default_model: String,
766    model_count: usize,
767}
768
769#[derive(Debug, Deserialize)]
770struct ToolRunRequest {
771    name: String,
772    #[serde(default)]
773    args: serde_json::Value,
774}
775
776#[derive(Debug, Serialize)]
777struct ToolRunResponse {
778    result: String,
779    label: String,
780}
781
782async fn handle_sync_blob(
783    stream: &mut TcpStream,
784    request: &Request,
785    path: &str,
786    state: &HostState,
787) -> io::Result<()> {
788    let query = split_target(&request.target).1;
789    let path_parts = path
790        .strip_prefix("/v1/sync/blob/")
791        .or_else(|| path.strip_prefix("/v1/sync/blobs/"))
792        .and_then(|rest| rest.split_once('/'));
793    let space_id = query_param(query, "space_id")
794        .or_else(|| path_parts.map(|(space, _)| percent_decode(space)));
795    let Some(space_id) = space_id else {
796        return respond_error(stream, 400, "sync blob is missing space_id").await;
797    };
798    let name =
799        query_param(query, "name").or_else(|| path_parts.map(|(_, name)| percent_decode(name)));
800    let Some(name) = name else {
801        return respond_error(stream, 400, "sync blob is missing name").await;
802    };
803    match request.method.as_str() {
804        "PUT" => {
805            if request.body.len() > MAX_BLOB_BYTES {
806                return respond_text(stream, 413, "blob too large").await;
807            }
808            let hash = query_param(query, "hash")
809                .or_else(|| request.headers.get("x-nexus-blob-hash").cloned())
810                .or_else(|| request.headers.get("x-content-sha256").cloned());
811            let Some(hash) = hash else {
812                return respond_error(stream, 400, "sync blob is missing hash").await;
813            };
814            let result = ask_actor(&state.requests, |reply| HostRequest::PutBlob {
815                space_id,
816                name,
817                hash,
818                bytes: request.body.clone(),
819                reply,
820            })
821            .await;
822            match result {
823                Ok(()) => {
824                    respond_json(
825                        stream,
826                        201,
827                        &serde_json::json!({ "ok": true, "bytes": request.body.len() }),
828                    )
829                    .await
830                }
831                Err(error) => respond_error(stream, 409, &error).await,
832            }
833        }
834        "GET" | "HEAD" => {
835            let result = ask_actor(&state.requests, |reply| HostRequest::GetBlob {
836                space_id,
837                name,
838                reply,
839            })
840            .await;
841            match result {
842                Ok(Some(bytes)) => {
843                    respond_full(
844                        stream,
845                        200,
846                        "application/octet-stream",
847                        "",
848                        &bytes,
849                        request.method == "HEAD",
850                    )
851                    .await
852                }
853                Ok(None) => respond_text(stream, 404, "sync blob not found").await,
854                Err(error) => respond_error(stream, 500, &error).await,
855            }
856        }
857        _ => respond_text(stream, 405, "method not allowed").await,
858    }
859}
860
861async fn handle_sse(
862    stream: &mut TcpStream,
863    request: &Request,
864    state: &HostState,
865) -> io::Result<()> {
866    if request.method != "GET" {
867        return respond_text(stream, 405, "method not allowed").await;
868    }
869    let mut receiver = state.events.subscribe();
870    stream
871        .write_all(
872            format!(
873                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n{CORS_HEADERS}\r\n"
874            )
875            .as_bytes(),
876        )
877        .await?;
878    stream.flush().await?;
879    let mut heartbeat = tokio::time::interval(SSE_HEARTBEAT);
880    heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
881    loop {
882        tokio::select! {
883            event = receiver.recv() => match event {
884                Ok(event) => {
885                    let Ok(json) = serde_json::to_string(&event) else {
886                        continue;
887                    };
888                    if stream.write_all(format!("data: {json}\n\n").as_bytes()).await.is_err() {
889                        return Ok(());
890                    }
891                    if stream.flush().await.is_err() {
892                        return Ok(());
893                    }
894                }
895                Err(broadcast::error::RecvError::Lagged(_)) => {
896                    if stream.write_all(b": lagged; refresh /v1/snapshot\n\n").await.is_err() {
897                        return Ok(());
898                    }
899                    let _ = stream.flush().await;
900                }
901                Err(broadcast::error::RecvError::Closed) => return Ok(()),
902            },
903            _ = heartbeat.tick() => {
904                if stream.write_all(b": heartbeat\n\n").await.is_err() {
905                    return Ok(());
906                }
907                if stream.flush().await.is_err() {
908                    return Ok(());
909                }
910            }
911        }
912    }
913}
914
915#[allow(clippy::too_many_lines)]
916async fn handle_gateway(
917    stream: &mut TcpStream,
918    request: &Request,
919    state: &HostState,
920) -> io::Result<()> {
921    if request.method != "POST" {
922        return respond_text(stream, 405, "method not allowed").await;
923    }
924    if request.body.len() > MAX_GATEWAY_BODY_BYTES {
925        return respond_text(stream, 413, "request entity too large").await;
926    }
927    let body: serde_json::Value = match serde_json::from_slice(&request.body) {
928        Ok(body) => body,
929        Err(error) => {
930            return respond_error(stream, 400, &format!("invalid completion request: {error}"))
931                .await;
932        }
933    };
934    let model = body
935        .get("model")
936        .and_then(serde_json::Value::as_str)
937        .unwrap_or_default();
938    if model.trim().is_empty() {
939        return respond_error(stream, 400, "completion request is missing model").await;
940    }
941    let override_tag = request
942        .headers
943        .get("x-nexus-backend")
944        .map(|value| parse_backend_tag(value))
945        .transpose();
946    let override_tag = match override_tag {
947        Ok(tag) => tag,
948        Err(error) => return respond_error(stream, 400, &error).await,
949    };
950    let route = ask_actor(&state.requests, |reply| HostRequest::GatewayRoute {
951        model: model.to_string(),
952        override_tag,
953        reply,
954    })
955    .await;
956    let route = match route {
957        Ok(route) => route,
958        Err(error) => {
959            let models = ask_actor(&state.requests, |reply| HostRequest::Models { reply })
960                .await
961                .unwrap_or_default();
962            return respond_json(
963                stream,
964                400,
965                &serde_json::json!({
966                    "error": { "message": error, "type": "invalid_request_error" },
967                    "models": models,
968                }),
969            )
970            .await;
971        }
972    };
973    let (default_base, raw_model) = openrouter::gateway_route(route.tag, &route.model);
974    let base = state.gateway_base.as_deref().unwrap_or(default_base);
975    let url = format!("{base}/chat/completions");
976    // `/v1/models` publishes composite ids (`openai:`/`codex:`/`opencode:`
977    // prefixes, plus the `go:` tag `list_models` adds), which no upstream
978    // recognizes. Forward the raw id the route resolved to, not the client's.
979    let payload = {
980        let mut forwarded = body.clone();
981        if let Some(object) = forwarded.as_object_mut() {
982            object.insert("model".to_string(), serde_json::Value::String(raw_model));
983        }
984        serde_json::to_vec(&forwarded).unwrap_or_else(|_| request.body.clone())
985    };
986    let mut builder = state
987        .client
988        .post(url)
989        .header(
990            reqwest::header::AUTHORIZATION,
991            format!("Bearer {}", route.key),
992        )
993        .body(payload);
994    for (name, value) in &request.headers {
995        if is_hop_by_hop(name) || name.starts_with("x-nexus-") {
996            continue;
997        }
998        let Ok(header_name) = reqwest::header::HeaderName::from_bytes(name.as_bytes()) else {
999            continue;
1000        };
1001        let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) else {
1002            continue;
1003        };
1004        builder = builder.header(header_name, header_value);
1005    }
1006    // Unreachable while `gateway_unsupported` rejects Codex; kept so the
1007    // headers are already correct when the gateway learns to translate.
1008    if route.tag == BackendTag::Codex {
1009        if let Some(account_id) = &route.account_id {
1010            builder = builder.header("chatgpt-account-id", account_id);
1011        }
1012        builder = builder
1013            .header("originator", "nexus-host")
1014            .header("OpenAI-Beta", "responses=experimental");
1015    }
1016    let response = match tokio::time::timeout(UPSTREAM_HEADERS_TIMEOUT, builder.send()).await {
1017        Ok(Ok(response)) => response,
1018        Ok(Err(error)) => {
1019            return respond_error(stream, 502, &format!("upstream request failed: {error}")).await;
1020        }
1021        Err(_) => return respond_error(stream, 504, "upstream request timed out").await,
1022    };
1023    let status = response.status().as_u16();
1024    let content_type = response
1025        .headers()
1026        .get(reqwest::header::CONTENT_TYPE)
1027        .and_then(|value| value.to_str().ok())
1028        .unwrap_or("application/octet-stream")
1029        .to_string();
1030    let streaming = content_type
1031        .to_ascii_lowercase()
1032        .contains("text/event-stream")
1033        || body
1034            .get("stream")
1035            .and_then(serde_json::Value::as_bool)
1036            .unwrap_or(false);
1037    let mut upstream = response.bytes_stream();
1038    let mut usage = GatewayUsage::default();
1039    let reason = reason(status);
1040    stream
1041        .write_all(
1042            format!(
1043                "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nCache-Control: no-cache\r\nConnection: close\r\n{CORS_HEADERS}\r\n"
1044            )
1045            .as_bytes(),
1046        )
1047        .await?;
1048    stream.flush().await?;
1049    loop {
1050        let Ok(next) = tokio::time::timeout(UPSTREAM_CHUNK_TIMEOUT, upstream.next()).await else {
1051            break;
1052        };
1053        let Some(chunk) = next else {
1054            break;
1055        };
1056        let Ok(chunk) = chunk else {
1057            break;
1058        };
1059        usage.observe(&chunk, streaming);
1060        if stream.write_all(&chunk).await.is_err() {
1061            return Ok(());
1062        }
1063        if stream.flush().await.is_err() {
1064            return Ok(());
1065        }
1066    }
1067    usage.finish(streaming);
1068    let (reply, result) = oneshot::channel();
1069    let _ = state
1070        .requests
1071        .send(HostRequest::LogGatewayUsage {
1072            route,
1073            usage,
1074            reply,
1075        })
1076        .await;
1077    let _ = result.await;
1078    stream.shutdown().await
1079}
1080
1081async fn proxy_app(
1082    stream: &mut TcpStream,
1083    request: &Request,
1084    path: &str,
1085    query: &str,
1086    state: &HostState,
1087) -> io::Result<()> {
1088    if !matches!(
1089        request.method.as_str(),
1090        "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "OPTIONS"
1091    ) {
1092        return respond_text(stream, 405, "method not allowed").await;
1093    }
1094    let Some(port) = state.app_server_port else {
1095        return respond_text(stream, 503, "app server unavailable").await;
1096    };
1097    let local_path = path.strip_prefix("/apps").unwrap_or("/");
1098    let query = query_without_token(query);
1099    let target = if query.is_empty() {
1100        format!("http://127.0.0.1:{port}{local_path}")
1101    } else {
1102        format!("http://127.0.0.1:{port}{local_path}?{query}")
1103    };
1104    let Ok(method) = reqwest::Method::from_bytes(request.method.as_bytes()) else {
1105        return respond_text(stream, 400, "invalid method").await;
1106    };
1107    let mut builder = state
1108        .client
1109        .request(method, target)
1110        .body(request.body.clone());
1111    for (name, value) in &request.headers {
1112        if is_hop_by_hop(name) || name == "cookie" || name == "x-nexus-token" {
1113            continue;
1114        }
1115        let Ok(header_name) = reqwest::header::HeaderName::from_bytes(name.as_bytes()) else {
1116            continue;
1117        };
1118        let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) else {
1119            continue;
1120        };
1121        builder = builder.header(header_name, header_value);
1122    }
1123    let response = match tokio::time::timeout(UPSTREAM_HEADERS_TIMEOUT, builder.send()).await {
1124        Ok(Ok(response)) => response,
1125        Ok(Err(error)) => {
1126            return respond_error(stream, 502, &format!("app proxy failed: {error}")).await;
1127        }
1128        Err(_) => return respond_error(stream, 504, "app proxy timed out").await,
1129    };
1130    let status = response.status().as_u16();
1131    let content_type = response
1132        .headers()
1133        .get(reqwest::header::CONTENT_TYPE)
1134        .and_then(|value| value.to_str().ok())
1135        .unwrap_or("application/octet-stream")
1136        .to_string();
1137    let mut body = Vec::new();
1138    let mut upstream = response.bytes_stream();
1139    loop {
1140        let Ok(next) = tokio::time::timeout(UPSTREAM_CHUNK_TIMEOUT, upstream.next()).await else {
1141            return respond_error(stream, 504, "app response timed out").await;
1142        };
1143        let Some(chunk) = next else {
1144            break;
1145        };
1146        let chunk = match chunk {
1147            Ok(chunk) => chunk,
1148            Err(error) => {
1149                return respond_error(stream, 502, &format!("app response failed: {error}")).await;
1150            }
1151        };
1152        if body.len().saturating_add(chunk.len()) > MAX_APP_RESPONSE_BYTES {
1153            return respond_text(stream, 502, "app response too large").await;
1154        }
1155        body.extend_from_slice(&chunk);
1156    }
1157    respond_full(
1158        stream,
1159        status,
1160        &content_type,
1161        "",
1162        &body,
1163        request.method == "HEAD",
1164    )
1165    .await
1166}
1167
1168/// Headers that must not be relayed to an upstream. `accept-encoding` is in
1169/// the list because both proxies forward the upstream body verbatim while
1170/// only relaying `Content-Type` — a compressed upstream response would
1171/// otherwise reach the client without its `Content-Encoding` (and defeat the
1172/// gateway's usage parsing).
1173fn is_hop_by_hop(name: &str) -> bool {
1174    matches!(
1175        name,
1176        "host"
1177            | "authorization"
1178            | "content-length"
1179            | "transfer-encoding"
1180            | "accept-encoding"
1181            | "connection"
1182            | "keep-alive"
1183            | "expect"
1184            | "te"
1185            | "trailer"
1186            | "upgrade"
1187            | "proxy-authenticate"
1188            | "proxy-authorization"
1189    )
1190}
1191
1192fn query_without_token(query: &str) -> String {
1193    query
1194        .split('&')
1195        .filter(|pair| {
1196            let pair = *pair;
1197            let name = pair.split_once('=').map_or(pair, |(name, _)| name);
1198            !pair.is_empty() && !name.eq_ignore_ascii_case("token")
1199        })
1200        .collect::<Vec<_>>()
1201        .join("&")
1202}
1203
1204fn redact_wire_event(event: WireEvent, app: &App, host_token: &str) -> WireEvent {
1205    let mut secrets = vec![
1206        host_token,
1207        app.saved.host_token.as_deref().unwrap_or_default(),
1208        app.saved.openrouter_key.as_deref().unwrap_or_default(),
1209        app.saved.openai_key.as_deref().unwrap_or_default(),
1210        app.saved.opencode_key.as_deref().unwrap_or_default(),
1211        app.saved
1212            .codex
1213            .as_ref()
1214            .map_or("", |credentials| credentials.access.as_str()),
1215        app.saved
1216            .codex
1217            .as_ref()
1218            .map_or("", |credentials| credentials.refresh.as_str()),
1219        app.langsearch_key.as_str(),
1220        app.searxng_url.as_str(),
1221    ];
1222    secrets.retain(|secret| !secret.is_empty());
1223    let Ok(mut value) = serde_json::to_value(event.clone()) else {
1224        return event;
1225    };
1226    redact_json_secrets(&mut value, &secrets);
1227    serde_json::from_value(value).unwrap_or(event)
1228}
1229
1230fn redact_json_secrets(value: &mut serde_json::Value, secrets: &[&str]) {
1231    match value {
1232        serde_json::Value::String(text) => {
1233            for secret in secrets {
1234                *text = text.replace(secret, "[redacted]");
1235            }
1236        }
1237        serde_json::Value::Array(values) => {
1238            for value in values {
1239                redact_json_secrets(value, secrets);
1240            }
1241        }
1242        serde_json::Value::Object(values) => {
1243            for value in values.values_mut() {
1244                redact_json_secrets(value, secrets);
1245            }
1246        }
1247        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
1248    }
1249}
1250
1251async fn app_actor(
1252    mut app: App,
1253    mut requests: mpsc::Receiver<HostRequest>,
1254    events: broadcast::Sender<WireEvent>,
1255    shutdown: Arc<Notify>,
1256    host_token: Arc<String>,
1257) {
1258    loop {
1259        tokio::select! {
1260            () = shutdown.notified() => break,
1261            request = requests.recv() => {
1262                let Some(request) = request else { break; };
1263                handle_actor_request(&mut app, request);
1264            }
1265            event = app.next_event() => {
1266                let wire = redact_wire_event(WireEvent::from(event.clone()), &app, host_token.as_str());
1267                // The handler may clear a one-shot receiver itself; this
1268                // backstop also prevents a closed source from returning
1269                // `None` forever when a remote client is the only consumer.
1270                clear_closed_source(&mut app, &event);
1271                apply_domain_event(&mut app, event);
1272                let _ = events.send(wire);
1273            }
1274        }
1275    }
1276}
1277
1278#[allow(clippy::too_many_lines)]
1279fn handle_actor_request(app: &mut App, request: HostRequest) {
1280    match request {
1281        HostRequest::Snapshot { reply } => {
1282            let _ = reply.send(app.snapshot().map_err(|error| error.to_string()));
1283        }
1284        HostRequest::Models { reply } => {
1285            // Gateway-unreachable backends are withheld: this list is what an
1286            // OpenAI-wire client picks from, and every pick must be routable.
1287            let models = app
1288                .models
1289                .iter()
1290                .filter(|entry| gateway_unsupported(entry.backend).is_none())
1291                .cloned()
1292                .map(WireModel::from)
1293                .collect();
1294            let _ = reply.send(Ok(models));
1295        }
1296        HostRequest::Backends { reply } => {
1297            let tags = [
1298                BackendTag::OpenRouter,
1299                BackendTag::OpenAi,
1300                BackendTag::OpencodeGo,
1301                BackendTag::Codex,
1302            ];
1303            let backends = tags
1304                .into_iter()
1305                .map(|tag| {
1306                    let provider = app.backends.get(tag);
1307                    BackendInfo {
1308                        tag: tag.into(),
1309                        name: tag.display_name(),
1310                        configured: app.backends.configured(tag),
1311                        gateway_supported: gateway_unsupported(tag).is_none(),
1312                        gateway_error: gateway_unsupported(tag),
1313                        default_model: provider.map_or_else(String::new, |provider| {
1314                            public_model_id_for_backend(tag, provider.default_utility_model())
1315                        }),
1316                        model_count: app
1317                            .models
1318                            .iter()
1319                            .filter(|model| model.backend == tag)
1320                            .count(),
1321                    }
1322                })
1323                .collect();
1324            let _ = reply.send(Ok(backends));
1325        }
1326        HostRequest::Command { command, reply } => {
1327            let result = app.execute(command).map_err(|error| error.to_string());
1328            let _ = reply.send(result);
1329        }
1330        HostRequest::ToolDefs { reply } => {
1331            let _ = reply.send(Ok(app.toolbox.defs()));
1332        }
1333        HostRequest::Toolbox { reply } => {
1334            let _ = reply.send(Ok(app.toolbox.clone()));
1335        }
1336        HostRequest::PutBlob {
1337            space_id,
1338            name,
1339            hash,
1340            bytes,
1341            reply,
1342        } => {
1343            let result =
1344                crate::sync::put_blob(&app.db, &app.space, &space_id, &name, &hash, &bytes)
1345                    .map_err(|error| error.to_string());
1346            if result.is_ok() {
1347                // Invalidate the stat shortcut too: a replacement upload can
1348                // have the same size and land in the same mtime second.
1349                if let Some(file) = app
1350                    .db
1351                    .list_files(&space_id)
1352                    .ok()
1353                    .and_then(|files| files.into_iter().find(|file| file.name == name))
1354                {
1355                    let _ = app.db.set_file_mtime(&file.id, 0);
1356                }
1357                if space_id == app.active_space.id {
1358                    // The blob route is also an upload path for the long-lived
1359                    // host actor; without a rescan it would exist on disk but
1360                    // never receive extracted chunks or background embeddings.
1361                    app.rescan_files();
1362                }
1363            }
1364            let _ = reply.send(result);
1365        }
1366        HostRequest::GetBlob {
1367            space_id,
1368            name,
1369            reply,
1370        } => {
1371            let result = crate::sync::read_blob(&app.db, &app.space, &space_id, &name)
1372                .map_err(|error| error.to_string());
1373            let _ = reply.send(result);
1374        }
1375        HostRequest::Sync { changeset, reply } => {
1376            let result = (|| -> Result<Changeset, String> {
1377                let (_summary, cursors) =
1378                    crate::sync::apply_changeset(&app.db, &app.space, &changeset, None)
1379                        .map_err(|error| error.to_string())?;
1380                app.sessions_cache.clear();
1381                app.files_cache = app.db.list_files(&app.active_space.id).unwrap_or_default();
1382                let mut reply_changeset = crate::sync::build_changeset(
1383                    &app.db,
1384                    Some(&changeset.device_id),
1385                    &crate::sync::device_name(),
1386                )
1387                .map_err(|error| error.to_string())?;
1388                reply_changeset.ack = Some(cursors);
1389                Ok(reply_changeset)
1390            })();
1391            let _ = reply.send(result);
1392        }
1393        HostRequest::SetPublicBase { base, reply } => {
1394            let result = if let Some(server) = app.app_server.as_mut() {
1395                server.set_public_base(base);
1396                app.refresh_toolbox();
1397                Ok(())
1398            } else {
1399                Err("app server is unavailable".to_string())
1400            };
1401            let _ = reply.send(result);
1402        }
1403        HostRequest::GatewayRoute {
1404            model,
1405            override_tag,
1406            reply,
1407        } => {
1408            let result = gateway_route(app, &model, override_tag);
1409            let _ = reply.send(result);
1410        }
1411        HostRequest::LogGatewayUsage {
1412            route,
1413            usage,
1414            reply,
1415        } => {
1416            let result = app
1417                .db
1418                .log_usage(
1419                    route.tag.name(),
1420                    &route.model,
1421                    usage.prompt_tokens,
1422                    usage.completion_tokens,
1423                    usage.cache_read_tokens,
1424                    usage.cache_creation_tokens,
1425                    usage.cost,
1426                    usage.cost.is_some(),
1427                    None,
1428                    Some(&app.active_space.id),
1429                )
1430                .map(|_| ())
1431                .map_err(|error| error.to_string());
1432            let _ = reply.send(result);
1433        }
1434    }
1435}
1436
1437fn apply_domain_event(app: &mut App, event: AppEvent) {
1438    match event {
1439        AppEvent::Stream(Some((task_id, event))) => {
1440            if let Err(error) = app.on_chat_event(task_id, event) {
1441                app.push_status(error.to_string());
1442            }
1443        }
1444        AppEvent::Models(result) => app.on_models_result(result),
1445        AppEvent::Title(result) => app.on_title_result(result),
1446        AppEvent::Memory(result) => app.on_memory_result(result),
1447        AppEvent::Compact(result) => app.on_compact_result(result),
1448        AppEvent::SkillInstall(result) => app.on_skill_install_result(result),
1449        AppEvent::Ocr(result) => app.on_ocr_done(result),
1450        AppEvent::Embed(result) => app.on_embed_done(result),
1451        AppEvent::OcrPull(result) => app.on_ocr_pull(result),
1452        AppEvent::Research(result) => app.on_research_done(result),
1453        AppEvent::ResearchTopic(result) => app.on_research_topic_derived(result),
1454        AppEvent::Login(result) => app.on_login_result(result),
1455        AppEvent::UpdateCheck(result) => app.on_update_check(result),
1456        AppEvent::Swarm(result) => app.on_swarm_update(result),
1457        AppEvent::Status(_)
1458        | AppEvent::ComposerSet(_)
1459        | AppEvent::ComposerClear
1460        | AppEvent::ViewportReset
1461        | AppEvent::HistoryInvalidated
1462        | AppEvent::OpenLoginPopup
1463        | AppEvent::Gate(_)
1464        | AppEvent::Stream(None) => {}
1465    }
1466}
1467
1468fn clear_closed_source(app: &mut App, event: &AppEvent) {
1469    match event {
1470        AppEvent::Models(None) => app.models_rx = None,
1471        AppEvent::Title(None) => app.title_rx = None,
1472        AppEvent::Memory(None) => app.memory_rx = None,
1473        AppEvent::Compact(None) => app.compact_rx = None,
1474        AppEvent::SkillInstall(None) => app.skills_rx = None,
1475        AppEvent::Ocr(None) => app.ocr_rx = None,
1476        AppEvent::Embed(None) => app.embed_rx = None,
1477        AppEvent::OcrPull(None) => app.ocr_pull_rx = None,
1478        AppEvent::Research(None) => app.research_rx = None,
1479        AppEvent::ResearchTopic(None) => app.research_topic_rx = None,
1480        AppEvent::Login(None) => app.login_rx = None,
1481        AppEvent::Swarm(None) => app.swarm_rx = None,
1482        AppEvent::UpdateCheck(None) => app.update_rx = None,
1483        _ => {}
1484    }
1485}
1486
1487/// Why a backend cannot be reached through the OpenAI-wire gateway, if it
1488/// cannot be.
1489///
1490/// Only Codex qualifies today. [`openrouter::base_url`] points it at
1491/// `https://chatgpt.com/backend-api`, which serves `/codex/responses` taking a
1492/// Responses-API body — but [`handle_gateway`] speaks `/chat/completions` and
1493/// pipes the upstream bytes straight back. A Codex route would therefore 404
1494/// upstream on every request, so it is rejected here and withheld from
1495/// `/v1/models` and `/v1/backends` rather than advertised as usable.
1496///
1497/// The request half of the translation already exists as
1498/// `openrouter::chat_body_to_codex_body`; the missing half is re-emitting the
1499/// Responses event stream as `chat.completion.chunk` frames. Delete this
1500/// function once the gateway can do that.
1501const fn gateway_unsupported(tag: BackendTag) -> Option<&'static str> {
1502    match tag {
1503        BackendTag::Codex => Some(
1504            "backend Codex is not reachable through the gateway: it speaks the Responses API, \
1505             which the gateway cannot translate yet",
1506        ),
1507        _ => None,
1508    }
1509}
1510
1511fn public_model_id_for_backend(tag: BackendTag, model: &str) -> String {
1512    format!("{}{}", tag.wire_prefix(), raw_model_for_backend(tag, model))
1513}
1514
1515fn raw_model_for_backend(tag: BackendTag, model: &str) -> String {
1516    model
1517        .strip_prefix(tag.wire_prefix())
1518        .or_else(|| {
1519            let prefix = tag.key_prefix();
1520            (!prefix.is_empty())
1521                .then(|| model.strip_prefix(prefix))
1522                .flatten()
1523        })
1524        .unwrap_or(model)
1525        .to_string()
1526}
1527
1528fn gateway_route(
1529    app: &App,
1530    model: &str,
1531    override_tag: Option<BackendTag>,
1532) -> Result<GatewayRoute, String> {
1533    // Checked before `configured`, so an explicit `x-nexus-backend: codex`
1534    // gets the real reason instead of advice to configure a backend that
1535    // still would not work.
1536    if let Some(tag) = override_tag
1537        && let Some(reason) = gateway_unsupported(tag)
1538    {
1539        return Err(reason.to_string());
1540    }
1541    let selected = if let Some(tag) = override_tag {
1542        if !app.backends.configured(tag) {
1543            return Err(format!("backend {} is not configured", tag.display_name()));
1544        }
1545        Some((tag, raw_model_for_backend(tag, model)))
1546    } else {
1547        app.models.iter().find_map(|entry| {
1548            let composite = crate::app::composite_id(entry);
1549            let public_id = public_model_id(entry);
1550            (entry.id == model || composite == model || public_id == model)
1551                .then_some((entry.backend, entry.id.clone()))
1552        })
1553    };
1554    let Some((tag, raw_model)) = selected else {
1555        return Err(format!("unknown model {model:?}"));
1556    };
1557    // A Codex model is still resolvable by raw id even though `/v1/models`
1558    // no longer lists it.
1559    if let Some(reason) = gateway_unsupported(tag) {
1560        return Err(reason.to_string());
1561    }
1562    let key = match tag {
1563        BackendTag::OpenRouter => app.saved.openrouter_key.clone(),
1564        BackendTag::OpenAi => app.saved.openai_key.clone(),
1565        BackendTag::OpencodeGo => app.saved.opencode_key.clone(),
1566        BackendTag::Codex => app
1567            .saved
1568            .codex
1569            .as_ref()
1570            .map(|credentials| credentials.access.clone()),
1571    }
1572    .ok_or_else(|| format!("backend {} is not configured", tag.display_name()))?;
1573    let account_id = app
1574        .saved
1575        .codex
1576        .as_ref()
1577        .map(|credentials| credentials.account_id.clone());
1578    Ok(GatewayRoute {
1579        tag,
1580        model: raw_model,
1581        key,
1582        account_id,
1583    })
1584}
1585
1586enum HostRequest {
1587    Snapshot {
1588        reply: oneshot::Sender<Result<CoreSnapshot, String>>,
1589    },
1590    Models {
1591        reply: oneshot::Sender<Result<Vec<WireModel>, String>>,
1592    },
1593    Backends {
1594        reply: oneshot::Sender<Result<Vec<BackendInfo>, String>>,
1595    },
1596    Command {
1597        command: AppCommand,
1598        reply: oneshot::Sender<Result<(), String>>,
1599    },
1600    ToolDefs {
1601        reply: oneshot::Sender<Result<Vec<crate::provider::ToolDef>, String>>,
1602    },
1603    Toolbox {
1604        reply: oneshot::Sender<Result<Arc<dyn ToolExecutor>, String>>,
1605    },
1606    Sync {
1607        changeset: Changeset,
1608        reply: oneshot::Sender<Result<Changeset, String>>,
1609    },
1610    PutBlob {
1611        space_id: String,
1612        name: String,
1613        hash: String,
1614        bytes: Vec<u8>,
1615        reply: oneshot::Sender<Result<(), String>>,
1616    },
1617    GetBlob {
1618        space_id: String,
1619        name: String,
1620        reply: oneshot::Sender<Result<Option<Vec<u8>>, String>>,
1621    },
1622    SetPublicBase {
1623        base: Option<String>,
1624        reply: oneshot::Sender<Result<(), String>>,
1625    },
1626    GatewayRoute {
1627        model: String,
1628        override_tag: Option<BackendTag>,
1629        reply: oneshot::Sender<Result<GatewayRoute, String>>,
1630    },
1631    LogGatewayUsage {
1632        route: GatewayRoute,
1633        usage: GatewayUsage,
1634        reply: oneshot::Sender<Result<(), String>>,
1635    },
1636}
1637
1638async fn ask_actor<T, F>(requests: &mpsc::Sender<HostRequest>, make: F) -> Result<T, String>
1639where
1640    F: FnOnce(oneshot::Sender<Result<T, String>>) -> HostRequest,
1641{
1642    let (reply, receiver) = oneshot::channel();
1643    requests
1644        .send(make(reply))
1645        .await
1646        .map_err(|_| "host actor stopped".to_string())?;
1647    tokio::time::timeout(ACTOR_REQUEST_TIMEOUT, receiver)
1648        .await
1649        .map_err(|_| "host actor request timed out".to_string())?
1650        .map_err(|_| "host actor stopped".to_string())?
1651}
1652
1653fn parse_backend_tag(value: &str) -> Result<BackendTag, String> {
1654    match value.trim().to_ascii_lowercase().as_str() {
1655        "openrouter" | "router" => Ok(BackendTag::OpenRouter),
1656        "openai" => Ok(BackendTag::OpenAi),
1657        "opencode" | "opencode-go" | "opencode_go" | "go" => Ok(BackendTag::OpencodeGo),
1658        "codex" => Ok(BackendTag::Codex),
1659        _ => Err(format!("unknown x-nexus-backend {value:?}")),
1660    }
1661}
1662
1663fn split_target(target: &str) -> (&str, &str) {
1664    target
1665        .split_once('?')
1666        .map_or((target, ""), |(path, query)| (path, query))
1667}
1668
1669fn query_param(query: &str, wanted: &str) -> Option<String> {
1670    query.split('&').find_map(|pair| {
1671        let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
1672        (percent_decode(name).eq_ignore_ascii_case(wanted)).then(|| percent_decode(value))
1673    })
1674}
1675
1676fn percent_decode(value: &str) -> String {
1677    let bytes = value.as_bytes();
1678    let mut output = Vec::with_capacity(bytes.len());
1679    let mut index = 0;
1680    while index < bytes.len() {
1681        if bytes[index] == b'%'
1682            && index + 2 < bytes.len()
1683            && let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2]))
1684        {
1685            output.push(high * 16 + low);
1686            index += 3;
1687        } else {
1688            output.push(if bytes[index] == b'+' {
1689                b' '
1690            } else {
1691                bytes[index]
1692            });
1693            index += 1;
1694        }
1695    }
1696    String::from_utf8_lossy(&output).into_owned()
1697}
1698
1699fn hex(value: u8) -> Option<u8> {
1700    match value {
1701        b'0'..=b'9' => Some(value - b'0'),
1702        b'a'..=b'f' => Some(value - b'a' + 10),
1703        b'A'..=b'F' => Some(value - b'A' + 10),
1704        _ => None,
1705    }
1706}
1707
1708async fn respond_text(stream: &mut TcpStream, status: u16, text: &str) -> io::Result<()> {
1709    respond_with_content_type(
1710        stream,
1711        status,
1712        "text/plain; charset=utf-8",
1713        text.as_bytes(),
1714        false,
1715    )
1716    .await
1717}
1718
1719async fn respond_error(stream: &mut TcpStream, status: u16, message: &str) -> io::Result<()> {
1720    respond_json(
1721        stream,
1722        status,
1723        &serde_json::json!({
1724            "error": { "message": message, "type": "invalid_request_error" }
1725        }),
1726    )
1727    .await
1728}
1729
1730async fn respond_json<T: Serialize>(
1731    stream: &mut TcpStream,
1732    status: u16,
1733    value: &T,
1734) -> io::Result<()> {
1735    let body = serde_json::to_vec(value)
1736        .unwrap_or_else(|_| b"{\"error\":{\"message\":\"serialization failed\"}}".to_vec());
1737    respond_with_content_type(
1738        stream,
1739        status,
1740        "application/json; charset=utf-8",
1741        &body,
1742        false,
1743    )
1744    .await
1745}
1746
1747async fn respond_empty(stream: &mut TcpStream, status: u16) -> io::Result<()> {
1748    respond_with_content_type(stream, status, "text/plain", &[], false).await
1749}
1750
1751async fn respond_with_content_type(
1752    stream: &mut TcpStream,
1753    status: u16,
1754    content_type: &str,
1755    body: &[u8],
1756    head: bool,
1757) -> io::Result<()> {
1758    respond_full(stream, status, content_type, "", body, head).await
1759}
1760
1761/// `extra` is a pre-rendered block of additional `Name: value\r\n` lines.
1762async fn respond_full(
1763    stream: &mut TcpStream,
1764    status: u16,
1765    content_type: &str,
1766    extra: &str,
1767    body: &[u8],
1768    head: bool,
1769) -> io::Result<()> {
1770    let header = format!(
1771        "HTTP/1.1 {status} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n{CORS_HEADERS}{extra}\r\n",
1772        reason(status),
1773        body.len(),
1774    );
1775    stream.write_all(header.as_bytes()).await?;
1776    if !head {
1777        stream.write_all(body).await?;
1778    }
1779    stream.shutdown().await
1780}
1781
1782fn reason(status: u16) -> &'static str {
1783    match status {
1784        200 => "OK",
1785        201 => "Created",
1786        202 => "Accepted",
1787        204 => "No Content",
1788        400 => "Bad Request",
1789        401 => "Unauthorized",
1790        404 => "Not Found",
1791        405 => "Method Not Allowed",
1792        413 => "Request Entity Too Large",
1793        431 => "Request Header Fields Too Large",
1794        500 => "Internal Server Error",
1795        501 => "Not Implemented",
1796        502 => "Bad Gateway",
1797        408 => "Request Timeout",
1798        409 => "Conflict",
1799        503 => "Service Unavailable",
1800        504 => "Gateway Timeout",
1801        _ => "Error",
1802    }
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807    use super::*;
1808    use sha2::Digest as _;
1809
1810    #[test]
1811    fn constant_time_compare_checks_length_and_bytes() {
1812        assert!(constant_time_eq(b"token", b"token"));
1813        assert!(!constant_time_eq(b"token", b"wrong"));
1814        assert!(!constant_time_eq(b"token", b"token-long"));
1815    }
1816
1817    #[test]
1818    fn backend_override_accepts_supported_spellings() {
1819        assert_eq!(parse_backend_tag("openrouter"), Ok(BackendTag::OpenRouter));
1820        assert_eq!(parse_backend_tag("OpenCode-Go"), Ok(BackendTag::OpencodeGo));
1821        assert!(parse_backend_tag("wat").is_err());
1822    }
1823
1824    #[test]
1825    fn query_token_is_removed_before_app_proxy() {
1826        assert_eq!(query_without_token("token=a&x=1&token=b"), "x=1");
1827    }
1828
1829    #[test]
1830    fn usage_observer_reads_openai_and_cache_fields() {
1831        let mut usage = GatewayUsage::default();
1832        usage.observe(
1833            br#"{"usage":{"prompt_tokens":10,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3},"cost":"0.01"}}"#,
1834            false,
1835        );
1836        usage.finish(false);
1837        assert_eq!(usage.prompt_tokens, 10);
1838        assert_eq!(usage.completion_tokens, 4);
1839        assert_eq!(usage.cache_read_tokens, 3);
1840        assert_eq!(usage.cost, Some(0.01));
1841    }
1842
1843    #[test]
1844    fn usage_survives_a_chunk_boundary_inside_the_sse_frame() {
1845        let frame = br#"data: {"usage":{"prompt_tokens":10,"completion_tokens":4}}"#;
1846        let (head, tail) = frame.split_at(20);
1847        let mut usage = GatewayUsage::default();
1848        usage.observe(head, true);
1849        usage.observe(tail, true);
1850        usage.finish(true);
1851        assert_eq!(usage.prompt_tokens, 10);
1852        assert_eq!(usage.completion_tokens, 4);
1853    }
1854
1855    #[test]
1856    fn wire_event_redacts_provider_and_host_credentials() {
1857        let mut app = test_app();
1858        app.saved.openai_key = Some("provider-secret".into());
1859        let event = redact_wire_event(
1860            WireEvent::Status("provider-secret host-secret".into()),
1861            &app,
1862            "host-secret",
1863        );
1864        let json = serde_json::to_string(&event).unwrap();
1865        assert!(!json.contains("provider-secret"));
1866        assert!(!json.contains("host-secret"));
1867        assert!(json.contains("[redacted]"));
1868    }
1869
1870    #[test]
1871    fn fresh_codex_login_never_reaches_host_redaction_with_credentials() {
1872        // The event is converted before the actor applies it to `App`, so the
1873        // newly-issued credentials are not present in `app.saved` yet. The
1874        // wire conversion must therefore remove them structurally rather than
1875        // relying on the later string-redaction backstop.
1876        let app = test_app();
1877        let event = AppEvent::Login(Some(crate::app::LoginMsg::Done(Ok(
1878            crate::config::CodexCredentials {
1879                access: "fresh-access-secret".into(),
1880                refresh: "fresh-refresh-secret".into(),
1881                expires: 123,
1882                account_id: "account".into(),
1883            },
1884        ))));
1885        let wire = redact_wire_event(WireEvent::from(event), &app, "host-secret");
1886        let json = serde_json::to_string(&wire).expect("serializes login event");
1887        assert!(!json.contains("fresh-access-secret"));
1888        assert!(!json.contains("fresh-refresh-secret"));
1889        assert_eq!(
1890            wire,
1891            WireEvent::Login(Some(crate::host::wire::WireLoginMsg::Done(Ok(()))))
1892        );
1893    }
1894
1895    #[test]
1896    fn codex_is_the_only_gateway_unreachable_backend() {
1897        assert!(gateway_unsupported(BackendTag::Codex).is_some());
1898        for tag in [
1899            BackendTag::OpenRouter,
1900            BackendTag::OpenAi,
1901            BackendTag::OpencodeGo,
1902        ] {
1903            assert!(gateway_unsupported(tag).is_none(), "{tag:?} should route");
1904        }
1905    }
1906
1907    fn test_app() -> App {
1908        let root = std::env::temp_dir().join(format!("nexus-host-test-{}", uuid::Uuid::new_v4()));
1909        App::new(
1910            crate::db::Db::open_in_memory().expect("in-memory db"),
1911            Some("sk-host-test"),
1912            crate::space::Space { root },
1913        )
1914    }
1915
1916    fn request(method: &str, target: &str, token: Option<&str>, body: &[u8]) -> Vec<u8> {
1917        let mut request = format!("{method} {target} HTTP/1.1\r\nHost: localhost\r\n").into_bytes();
1918        if let Some(token) = token {
1919            request.extend_from_slice(format!("Authorization: Bearer {token}\r\n").as_bytes());
1920        }
1921        request.extend_from_slice(
1922            format!(
1923                "Content-Length: {}\r\nConnection: close\r\n\r\n",
1924                body.len()
1925            )
1926            .as_bytes(),
1927        );
1928        request.extend_from_slice(body);
1929        request
1930    }
1931
1932    async fn raw_request(addr: SocketAddr, request: Vec<u8>) -> Vec<u8> {
1933        let mut stream = TcpStream::connect(addr).await.expect("connect host");
1934        stream.write_all(&request).await.expect("write request");
1935        stream.shutdown().await.expect("shutdown request");
1936        let mut response = Vec::new();
1937        stream
1938            .read_to_end(&mut response)
1939            .await
1940            .expect("read response");
1941        response
1942    }
1943
1944    fn response_body(response: &[u8]) -> &[u8] {
1945        response
1946            .windows(4)
1947            .position(|window| window == b"\r\n\r\n")
1948            .map_or(&[], |position| &response[position + 4..])
1949    }
1950
1951    #[tokio::test]
1952    async fn host_http_auth_models_command_and_snapshot_are_hermetic() {
1953        let mut app = test_app();
1954        app.models = vec![crate::provider::Model {
1955            id: "gpt-test".into(),
1956            name: "Test model".into(),
1957            reasoning_efforts: Vec::new(),
1958            context_length: Some(8_192),
1959            supports_images: false,
1960            supports_image_generation: false,
1961            supports_video_generation: false,
1962            backend: BackendTag::OpenAi,
1963            pricing: None,
1964        }];
1965        let mut server = HostServer::bind(app, HostConfig::new(0, "host-secret"))
1966            .await
1967            .unwrap();
1968        let addr = server.local_addr();
1969
1970        let unauthorized = raw_request(addr, request("GET", "/v1/snapshot", None, &[])).await;
1971        assert!(String::from_utf8_lossy(&unauthorized).starts_with("HTTP/1.1 401"));
1972
1973        let models =
1974            raw_request(addr, request("GET", "/v1/models", Some("host-secret"), &[])).await;
1975        assert!(String::from_utf8_lossy(&models).starts_with("HTTP/1.1 200"));
1976        let models: serde_json::Value = serde_json::from_slice(response_body(&models)).unwrap();
1977        assert_eq!(models["object"], "list");
1978        assert_eq!(models["data"][0]["object"], "model");
1979        assert_eq!(models["data"][0]["id"], "openai:gpt-test");
1980        assert_eq!(models["data"][0]["owned_by"], "openai");
1981
1982        let command = serde_json::to_vec(&AppCommand::SetSetting {
1983            key: "langsearch_key".into(),
1984            value: "do-not-leak".into(),
1985        })
1986        .unwrap();
1987        let command_response = raw_request(
1988            addr,
1989            request("POST", "/v1/command", Some("host-secret"), &command),
1990        )
1991        .await;
1992        assert!(String::from_utf8_lossy(&command_response).starts_with("HTTP/1.1 202"));
1993        tokio::time::sleep(Duration::from_millis(20)).await;
1994        let snapshot = raw_request(
1995            addr,
1996            request("GET", "/v1/snapshot", Some("host-secret"), &[]),
1997        )
1998        .await;
1999        let snapshot_text = String::from_utf8_lossy(response_body(&snapshot));
2000        assert!(!snapshot_text.contains("do-not-leak"));
2001        assert!(snapshot_text.contains("langsearch_configured"));
2002
2003        server.shutdown().await;
2004    }
2005
2006    #[tokio::test]
2007    async fn host_gateway_preserves_mocked_stream_bytes() {
2008        let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
2009        let mock_addr = listener.local_addr().unwrap();
2010        let expected = br#"data: {"choices":[{"delta":{"content":"hi"}}]}
2011
2012data: [DONE]
2013
2014"#;
2015        let expected_for_task = expected.to_vec();
2016        let mock = tokio::spawn(async move {
2017            let (mut stream, _) = listener.accept().await.unwrap();
2018            let mut request = Vec::new();
2019            loop {
2020                let mut chunk = [0u8; 1024];
2021                let n = stream.read(&mut chunk).await.unwrap();
2022                assert!(n > 0);
2023                request.extend_from_slice(&chunk[..n]);
2024                if request.windows(4).any(|window| window == b"\r\n\r\n") {
2025                    break;
2026                }
2027            }
2028            let request_text = String::from_utf8_lossy(&request);
2029            assert!(request_text.starts_with("POST /chat/completions HTTP/1.1"));
2030            assert!(request_text.contains("authorization: Bearer sk-host-test"));
2031            let header = format!(
2032                "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n",
2033                expected_for_task.len()
2034            );
2035            stream.write_all(header.as_bytes()).await.unwrap();
2036            stream.write_all(&expected_for_task).await.unwrap();
2037        });
2038
2039        let mut app = test_app();
2040        app.models = vec![crate::provider::Model {
2041            id: "gpt-test".into(),
2042            name: "Test model".into(),
2043            reasoning_efforts: Vec::new(),
2044            context_length: None,
2045            supports_images: false,
2046            supports_image_generation: false,
2047            supports_video_generation: false,
2048            backend: BackendTag::OpenAi,
2049            pricing: None,
2050        }];
2051        let mut server = HostServer::bind(
2052            app,
2053            HostConfig::new(0, "host-secret").with_gateway_base(format!("http://{mock_addr}")),
2054        )
2055        .await
2056        .unwrap();
2057        let body = br#"{"model":"openai:gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}"#;
2058        let response = raw_request(
2059            server.local_addr(),
2060            request("POST", "/v1/chat/completions", Some("host-secret"), body),
2061        )
2062        .await;
2063        assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 200"));
2064        assert!(response.ends_with(expected));
2065        mock.await.unwrap();
2066        server.shutdown().await;
2067    }
2068
2069    #[tokio::test]
2070    async fn host_sse_and_http_blob_transfer_are_hermetic() {
2071        let mut app = test_app();
2072        // The upload path rescans and starts semantic backfill; keep this
2073        // transport-only test independent of the fake provider key.
2074        app.embedding_model.clear();
2075        let space_id = app.active_space.id.clone();
2076        let name = "remote.txt";
2077        let bytes = b"blob over http";
2078        let digest = sha2::Sha256::digest(bytes);
2079        let hash = digest
2080            .iter()
2081            .map(|byte| format!("{byte:02x}"))
2082            .collect::<String>();
2083        app.db
2084            .upsert_file(&space_id, name, &hash, bytes.len() as i64, "ok")
2085            .unwrap();
2086        let mut server = HostServer::bind(app, HostConfig::new(0, "host-secret"))
2087            .await
2088            .unwrap();
2089        let addr = server.local_addr();
2090
2091        let mut events = TcpStream::connect(addr).await.unwrap();
2092        events
2093            .write_all(&request("GET", "/v1/events", Some("host-secret"), &[]))
2094            .await
2095            .unwrap();
2096        let mut initial = Vec::new();
2097        while !initial.windows(4).any(|window| window == b"\r\n\r\n") {
2098            let mut chunk = [0u8; 1024];
2099            let n = tokio::time::timeout(Duration::from_secs(2), events.read(&mut chunk))
2100                .await
2101                .unwrap()
2102                .unwrap();
2103            assert!(n > 0);
2104            initial.extend_from_slice(&chunk[..n]);
2105        }
2106
2107        let setting = serde_json::to_vec(&AppCommand::Send {
2108            text: "event-secret".into(),
2109        })
2110        .unwrap();
2111        let _ = raw_request(
2112            addr,
2113            request("POST", "/v1/command", Some("host-secret"), &setting),
2114        )
2115        .await;
2116        let mut frame_bytes = initial;
2117        while !String::from_utf8_lossy(&frame_bytes).contains("composer_set") {
2118            let mut chunk = [0u8; 1024];
2119            let n = tokio::time::timeout(Duration::from_secs(2), events.read(&mut chunk))
2120                .await
2121                .unwrap()
2122                .unwrap();
2123            assert!(n > 0);
2124            frame_bytes.extend_from_slice(&chunk[..n]);
2125        }
2126        assert!(!String::from_utf8_lossy(&frame_bytes).contains("host-secret"));
2127        events.shutdown().await.unwrap();
2128
2129        let target = format!("/v1/sync/blob?space_id={space_id}&name={name}&hash={hash}");
2130        let uploaded = raw_request(addr, request("PUT", &target, Some("host-secret"), bytes)).await;
2131        assert!(String::from_utf8_lossy(&uploaded).starts_with("HTTP/1.1 201"));
2132        let downloaded = raw_request(
2133            addr,
2134            request(
2135                "GET",
2136                &format!("/v1/sync/blob?space_id={space_id}&name={name}"),
2137                Some("host-secret"),
2138                &[],
2139            ),
2140        )
2141        .await;
2142        assert_eq!(response_body(&downloaded), bytes);
2143        server.shutdown().await;
2144    }
2145}