1use 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;
35const BODY_CHUNK_BYTES: usize = 64 * 1024;
37const 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;
47const 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#[derive(Debug, Clone)]
55pub struct HostConfig {
56 pub port: u16,
59 pub token: String,
62 gateway_base: Option<String>,
65}
66
67impl HostConfig {
68 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 #[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#[derive(Clone)]
90struct GatewayRoute {
91 tag: BackendTag,
92 model: String,
93 key: String,
94 account_id: Option<String>,
95}
96
97#[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 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 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 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
195pub 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 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 pub const fn local_addr(&self) -> SocketAddr {
278 self.addr
279 }
280
281 pub fn subscribe(&self) -> broadcast::Receiver<WireEvent> {
285 self.state.events.subscribe()
286 }
287
288 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 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
457async 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 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
569fn 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#[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 gateway_supported: bool,
762 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 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 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
1168fn 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 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 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 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 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
1487const 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 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 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
1761async 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 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 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}