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 app.files_cache = app.db.list_files(&app.active_space.id).unwrap_or_default();
1348 }
1349 let _ = reply.send(result);
1350 }
1351 HostRequest::GetBlob {
1352 space_id,
1353 name,
1354 reply,
1355 } => {
1356 let result = crate::sync::read_blob(&app.db, &app.space, &space_id, &name)
1357 .map_err(|error| error.to_string());
1358 let _ = reply.send(result);
1359 }
1360 HostRequest::Sync { changeset, reply } => {
1361 let result = (|| -> Result<Changeset, String> {
1362 let (_summary, cursors) =
1363 crate::sync::apply_changeset(&app.db, &app.space, &changeset, None)
1364 .map_err(|error| error.to_string())?;
1365 app.sessions_cache.clear();
1366 app.files_cache = app.db.list_files(&app.active_space.id).unwrap_or_default();
1367 let mut reply_changeset = crate::sync::build_changeset(
1368 &app.db,
1369 Some(&changeset.device_id),
1370 &crate::sync::device_name(),
1371 )
1372 .map_err(|error| error.to_string())?;
1373 reply_changeset.ack = Some(cursors);
1374 Ok(reply_changeset)
1375 })();
1376 let _ = reply.send(result);
1377 }
1378 HostRequest::SetPublicBase { base, reply } => {
1379 let result = if let Some(server) = app.app_server.as_mut() {
1380 server.set_public_base(base);
1381 app.refresh_toolbox();
1382 Ok(())
1383 } else {
1384 Err("app server is unavailable".to_string())
1385 };
1386 let _ = reply.send(result);
1387 }
1388 HostRequest::GatewayRoute {
1389 model,
1390 override_tag,
1391 reply,
1392 } => {
1393 let result = gateway_route(app, &model, override_tag);
1394 let _ = reply.send(result);
1395 }
1396 HostRequest::LogGatewayUsage {
1397 route,
1398 usage,
1399 reply,
1400 } => {
1401 let result = app
1402 .db
1403 .log_usage(
1404 route.tag.name(),
1405 &route.model,
1406 usage.prompt_tokens,
1407 usage.completion_tokens,
1408 usage.cache_read_tokens,
1409 usage.cache_creation_tokens,
1410 usage.cost,
1411 usage.cost.is_some(),
1412 None,
1413 Some(&app.active_space.id),
1414 )
1415 .map(|_| ())
1416 .map_err(|error| error.to_string());
1417 let _ = reply.send(result);
1418 }
1419 }
1420}
1421
1422fn apply_domain_event(app: &mut App, event: AppEvent) {
1423 match event {
1424 AppEvent::Stream(Some((task_id, event))) => {
1425 if let Err(error) = app.on_chat_event(task_id, event) {
1426 app.push_status(error.to_string());
1427 }
1428 }
1429 AppEvent::Models(result) => app.on_models_result(result),
1430 AppEvent::Title(result) => app.on_title_result(result),
1431 AppEvent::Memory(result) => app.on_memory_result(result),
1432 AppEvent::Compact(result) => app.on_compact_result(result),
1433 AppEvent::SkillInstall(result) => app.on_skill_install_result(result),
1434 AppEvent::Ocr(result) => app.on_ocr_done(result),
1435 AppEvent::Embed(result) => app.on_embed_done(result),
1436 AppEvent::OcrPull(result) => app.on_ocr_pull(result),
1437 AppEvent::Research(result) => app.on_research_done(result),
1438 AppEvent::ResearchTopic(result) => app.on_research_topic_derived(result),
1439 AppEvent::Login(result) => app.on_login_result(result),
1440 AppEvent::UpdateCheck(result) => app.on_update_check(result),
1441 AppEvent::Swarm(result) => app.on_swarm_update(result),
1442 AppEvent::Status(_)
1443 | AppEvent::ComposerSet(_)
1444 | AppEvent::ComposerClear
1445 | AppEvent::ViewportReset
1446 | AppEvent::HistoryInvalidated
1447 | AppEvent::OpenLoginPopup
1448 | AppEvent::Gate(_)
1449 | AppEvent::Stream(None) => {}
1450 }
1451}
1452
1453fn clear_closed_source(app: &mut App, event: &AppEvent) {
1454 match event {
1455 AppEvent::Models(None) => app.models_rx = None,
1456 AppEvent::Title(None) => app.title_rx = None,
1457 AppEvent::Memory(None) => app.memory_rx = None,
1458 AppEvent::Compact(None) => app.compact_rx = None,
1459 AppEvent::SkillInstall(None) => app.skills_rx = None,
1460 AppEvent::Ocr(None) => app.ocr_rx = None,
1461 AppEvent::Embed(None) => app.embed_rx = None,
1462 AppEvent::OcrPull(None) => app.ocr_pull_rx = None,
1463 AppEvent::Research(None) => app.research_rx = None,
1464 AppEvent::ResearchTopic(None) => app.research_topic_rx = None,
1465 AppEvent::Login(None) => app.login_rx = None,
1466 AppEvent::Swarm(None) => app.swarm_rx = None,
1467 AppEvent::UpdateCheck(None) => app.update_rx = None,
1468 _ => {}
1469 }
1470}
1471
1472const fn gateway_unsupported(tag: BackendTag) -> Option<&'static str> {
1487 match tag {
1488 BackendTag::Codex => Some(
1489 "backend Codex is not reachable through the gateway: it speaks the Responses API, \
1490 which the gateway cannot translate yet",
1491 ),
1492 _ => None,
1493 }
1494}
1495
1496fn public_model_id_for_backend(tag: BackendTag, model: &str) -> String {
1497 format!("{}{}", tag.wire_prefix(), raw_model_for_backend(tag, model))
1498}
1499
1500fn raw_model_for_backend(tag: BackendTag, model: &str) -> String {
1501 model
1502 .strip_prefix(tag.wire_prefix())
1503 .or_else(|| {
1504 let prefix = tag.key_prefix();
1505 (!prefix.is_empty())
1506 .then(|| model.strip_prefix(prefix))
1507 .flatten()
1508 })
1509 .unwrap_or(model)
1510 .to_string()
1511}
1512
1513fn gateway_route(
1514 app: &App,
1515 model: &str,
1516 override_tag: Option<BackendTag>,
1517) -> Result<GatewayRoute, String> {
1518 if let Some(tag) = override_tag
1522 && let Some(reason) = gateway_unsupported(tag)
1523 {
1524 return Err(reason.to_string());
1525 }
1526 let selected = if let Some(tag) = override_tag {
1527 if !app.backends.configured(tag) {
1528 return Err(format!("backend {} is not configured", tag.display_name()));
1529 }
1530 Some((tag, raw_model_for_backend(tag, model)))
1531 } else {
1532 app.models.iter().find_map(|entry| {
1533 let composite = crate::app::composite_id(entry);
1534 let public_id = public_model_id(entry);
1535 (entry.id == model || composite == model || public_id == model)
1536 .then_some((entry.backend, entry.id.clone()))
1537 })
1538 };
1539 let Some((tag, raw_model)) = selected else {
1540 return Err(format!("unknown model {model:?}"));
1541 };
1542 if let Some(reason) = gateway_unsupported(tag) {
1545 return Err(reason.to_string());
1546 }
1547 let key = match tag {
1548 BackendTag::OpenRouter => app.saved.openrouter_key.clone(),
1549 BackendTag::OpenAi => app.saved.openai_key.clone(),
1550 BackendTag::OpencodeGo => app.saved.opencode_key.clone(),
1551 BackendTag::Codex => app
1552 .saved
1553 .codex
1554 .as_ref()
1555 .map(|credentials| credentials.access.clone()),
1556 }
1557 .ok_or_else(|| format!("backend {} is not configured", tag.display_name()))?;
1558 let account_id = app
1559 .saved
1560 .codex
1561 .as_ref()
1562 .map(|credentials| credentials.account_id.clone());
1563 Ok(GatewayRoute {
1564 tag,
1565 model: raw_model,
1566 key,
1567 account_id,
1568 })
1569}
1570
1571enum HostRequest {
1572 Snapshot {
1573 reply: oneshot::Sender<Result<CoreSnapshot, String>>,
1574 },
1575 Models {
1576 reply: oneshot::Sender<Result<Vec<WireModel>, String>>,
1577 },
1578 Backends {
1579 reply: oneshot::Sender<Result<Vec<BackendInfo>, String>>,
1580 },
1581 Command {
1582 command: AppCommand,
1583 reply: oneshot::Sender<Result<(), String>>,
1584 },
1585 ToolDefs {
1586 reply: oneshot::Sender<Result<Vec<crate::provider::ToolDef>, String>>,
1587 },
1588 Toolbox {
1589 reply: oneshot::Sender<Result<Arc<dyn ToolExecutor>, String>>,
1590 },
1591 Sync {
1592 changeset: Changeset,
1593 reply: oneshot::Sender<Result<Changeset, String>>,
1594 },
1595 PutBlob {
1596 space_id: String,
1597 name: String,
1598 hash: String,
1599 bytes: Vec<u8>,
1600 reply: oneshot::Sender<Result<(), String>>,
1601 },
1602 GetBlob {
1603 space_id: String,
1604 name: String,
1605 reply: oneshot::Sender<Result<Option<Vec<u8>>, String>>,
1606 },
1607 SetPublicBase {
1608 base: Option<String>,
1609 reply: oneshot::Sender<Result<(), String>>,
1610 },
1611 GatewayRoute {
1612 model: String,
1613 override_tag: Option<BackendTag>,
1614 reply: oneshot::Sender<Result<GatewayRoute, String>>,
1615 },
1616 LogGatewayUsage {
1617 route: GatewayRoute,
1618 usage: GatewayUsage,
1619 reply: oneshot::Sender<Result<(), String>>,
1620 },
1621}
1622
1623async fn ask_actor<T, F>(requests: &mpsc::Sender<HostRequest>, make: F) -> Result<T, String>
1624where
1625 F: FnOnce(oneshot::Sender<Result<T, String>>) -> HostRequest,
1626{
1627 let (reply, receiver) = oneshot::channel();
1628 requests
1629 .send(make(reply))
1630 .await
1631 .map_err(|_| "host actor stopped".to_string())?;
1632 tokio::time::timeout(ACTOR_REQUEST_TIMEOUT, receiver)
1633 .await
1634 .map_err(|_| "host actor request timed out".to_string())?
1635 .map_err(|_| "host actor stopped".to_string())?
1636}
1637
1638fn parse_backend_tag(value: &str) -> Result<BackendTag, String> {
1639 match value.trim().to_ascii_lowercase().as_str() {
1640 "openrouter" | "router" => Ok(BackendTag::OpenRouter),
1641 "openai" => Ok(BackendTag::OpenAi),
1642 "opencode" | "opencode-go" | "opencode_go" | "go" => Ok(BackendTag::OpencodeGo),
1643 "codex" => Ok(BackendTag::Codex),
1644 _ => Err(format!("unknown x-nexus-backend {value:?}")),
1645 }
1646}
1647
1648fn split_target(target: &str) -> (&str, &str) {
1649 target
1650 .split_once('?')
1651 .map_or((target, ""), |(path, query)| (path, query))
1652}
1653
1654fn query_param(query: &str, wanted: &str) -> Option<String> {
1655 query.split('&').find_map(|pair| {
1656 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
1657 (percent_decode(name).eq_ignore_ascii_case(wanted)).then(|| percent_decode(value))
1658 })
1659}
1660
1661fn percent_decode(value: &str) -> String {
1662 let bytes = value.as_bytes();
1663 let mut output = Vec::with_capacity(bytes.len());
1664 let mut index = 0;
1665 while index < bytes.len() {
1666 if bytes[index] == b'%'
1667 && index + 2 < bytes.len()
1668 && let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2]))
1669 {
1670 output.push(high * 16 + low);
1671 index += 3;
1672 } else {
1673 output.push(if bytes[index] == b'+' {
1674 b' '
1675 } else {
1676 bytes[index]
1677 });
1678 index += 1;
1679 }
1680 }
1681 String::from_utf8_lossy(&output).into_owned()
1682}
1683
1684fn hex(value: u8) -> Option<u8> {
1685 match value {
1686 b'0'..=b'9' => Some(value - b'0'),
1687 b'a'..=b'f' => Some(value - b'a' + 10),
1688 b'A'..=b'F' => Some(value - b'A' + 10),
1689 _ => None,
1690 }
1691}
1692
1693async fn respond_text(stream: &mut TcpStream, status: u16, text: &str) -> io::Result<()> {
1694 respond_with_content_type(
1695 stream,
1696 status,
1697 "text/plain; charset=utf-8",
1698 text.as_bytes(),
1699 false,
1700 )
1701 .await
1702}
1703
1704async fn respond_error(stream: &mut TcpStream, status: u16, message: &str) -> io::Result<()> {
1705 respond_json(
1706 stream,
1707 status,
1708 &serde_json::json!({
1709 "error": { "message": message, "type": "invalid_request_error" }
1710 }),
1711 )
1712 .await
1713}
1714
1715async fn respond_json<T: Serialize>(
1716 stream: &mut TcpStream,
1717 status: u16,
1718 value: &T,
1719) -> io::Result<()> {
1720 let body = serde_json::to_vec(value)
1721 .unwrap_or_else(|_| b"{\"error\":{\"message\":\"serialization failed\"}}".to_vec());
1722 respond_with_content_type(
1723 stream,
1724 status,
1725 "application/json; charset=utf-8",
1726 &body,
1727 false,
1728 )
1729 .await
1730}
1731
1732async fn respond_empty(stream: &mut TcpStream, status: u16) -> io::Result<()> {
1733 respond_with_content_type(stream, status, "text/plain", &[], false).await
1734}
1735
1736async fn respond_with_content_type(
1737 stream: &mut TcpStream,
1738 status: u16,
1739 content_type: &str,
1740 body: &[u8],
1741 head: bool,
1742) -> io::Result<()> {
1743 respond_full(stream, status, content_type, "", body, head).await
1744}
1745
1746async fn respond_full(
1748 stream: &mut TcpStream,
1749 status: u16,
1750 content_type: &str,
1751 extra: &str,
1752 body: &[u8],
1753 head: bool,
1754) -> io::Result<()> {
1755 let header = format!(
1756 "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",
1757 reason(status),
1758 body.len(),
1759 );
1760 stream.write_all(header.as_bytes()).await?;
1761 if !head {
1762 stream.write_all(body).await?;
1763 }
1764 stream.shutdown().await
1765}
1766
1767fn reason(status: u16) -> &'static str {
1768 match status {
1769 200 => "OK",
1770 201 => "Created",
1771 202 => "Accepted",
1772 204 => "No Content",
1773 400 => "Bad Request",
1774 401 => "Unauthorized",
1775 404 => "Not Found",
1776 405 => "Method Not Allowed",
1777 413 => "Request Entity Too Large",
1778 431 => "Request Header Fields Too Large",
1779 500 => "Internal Server Error",
1780 501 => "Not Implemented",
1781 502 => "Bad Gateway",
1782 408 => "Request Timeout",
1783 409 => "Conflict",
1784 503 => "Service Unavailable",
1785 504 => "Gateway Timeout",
1786 _ => "Error",
1787 }
1788}
1789
1790#[cfg(test)]
1791mod tests {
1792 use super::*;
1793 use sha2::Digest as _;
1794
1795 #[test]
1796 fn constant_time_compare_checks_length_and_bytes() {
1797 assert!(constant_time_eq(b"token", b"token"));
1798 assert!(!constant_time_eq(b"token", b"wrong"));
1799 assert!(!constant_time_eq(b"token", b"token-long"));
1800 }
1801
1802 #[test]
1803 fn backend_override_accepts_supported_spellings() {
1804 assert_eq!(parse_backend_tag("openrouter"), Ok(BackendTag::OpenRouter));
1805 assert_eq!(parse_backend_tag("OpenCode-Go"), Ok(BackendTag::OpencodeGo));
1806 assert!(parse_backend_tag("wat").is_err());
1807 }
1808
1809 #[test]
1810 fn query_token_is_removed_before_app_proxy() {
1811 assert_eq!(query_without_token("token=a&x=1&token=b"), "x=1");
1812 }
1813
1814 #[test]
1815 fn usage_observer_reads_openai_and_cache_fields() {
1816 let mut usage = GatewayUsage::default();
1817 usage.observe(
1818 br#"{"usage":{"prompt_tokens":10,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3},"cost":"0.01"}}"#,
1819 false,
1820 );
1821 usage.finish(false);
1822 assert_eq!(usage.prompt_tokens, 10);
1823 assert_eq!(usage.completion_tokens, 4);
1824 assert_eq!(usage.cache_read_tokens, 3);
1825 assert_eq!(usage.cost, Some(0.01));
1826 }
1827
1828 #[test]
1829 fn usage_survives_a_chunk_boundary_inside_the_sse_frame() {
1830 let frame = br#"data: {"usage":{"prompt_tokens":10,"completion_tokens":4}}"#;
1831 let (head, tail) = frame.split_at(20);
1832 let mut usage = GatewayUsage::default();
1833 usage.observe(head, true);
1834 usage.observe(tail, true);
1835 usage.finish(true);
1836 assert_eq!(usage.prompt_tokens, 10);
1837 assert_eq!(usage.completion_tokens, 4);
1838 }
1839
1840 #[test]
1841 fn wire_event_redacts_provider_and_host_credentials() {
1842 let mut app = test_app();
1843 app.saved.openai_key = Some("provider-secret".into());
1844 let event = redact_wire_event(
1845 WireEvent::Status("provider-secret host-secret".into()),
1846 &app,
1847 "host-secret",
1848 );
1849 let json = serde_json::to_string(&event).unwrap();
1850 assert!(!json.contains("provider-secret"));
1851 assert!(!json.contains("host-secret"));
1852 assert!(json.contains("[redacted]"));
1853 }
1854
1855 #[test]
1856 fn codex_is_the_only_gateway_unreachable_backend() {
1857 assert!(gateway_unsupported(BackendTag::Codex).is_some());
1858 for tag in [
1859 BackendTag::OpenRouter,
1860 BackendTag::OpenAi,
1861 BackendTag::OpencodeGo,
1862 ] {
1863 assert!(gateway_unsupported(tag).is_none(), "{tag:?} should route");
1864 }
1865 }
1866
1867 fn test_app() -> App {
1868 let root = std::env::temp_dir().join(format!("nexus-host-test-{}", uuid::Uuid::new_v4()));
1869 App::new(
1870 crate::db::Db::open_in_memory().expect("in-memory db"),
1871 Some("sk-host-test"),
1872 crate::space::Space { root },
1873 )
1874 }
1875
1876 fn request(method: &str, target: &str, token: Option<&str>, body: &[u8]) -> Vec<u8> {
1877 let mut request = format!("{method} {target} HTTP/1.1\r\nHost: localhost\r\n").into_bytes();
1878 if let Some(token) = token {
1879 request.extend_from_slice(format!("Authorization: Bearer {token}\r\n").as_bytes());
1880 }
1881 request.extend_from_slice(
1882 format!(
1883 "Content-Length: {}\r\nConnection: close\r\n\r\n",
1884 body.len()
1885 )
1886 .as_bytes(),
1887 );
1888 request.extend_from_slice(body);
1889 request
1890 }
1891
1892 async fn raw_request(addr: SocketAddr, request: Vec<u8>) -> Vec<u8> {
1893 let mut stream = TcpStream::connect(addr).await.expect("connect host");
1894 stream.write_all(&request).await.expect("write request");
1895 stream.shutdown().await.expect("shutdown request");
1896 let mut response = Vec::new();
1897 stream
1898 .read_to_end(&mut response)
1899 .await
1900 .expect("read response");
1901 response
1902 }
1903
1904 fn response_body(response: &[u8]) -> &[u8] {
1905 response
1906 .windows(4)
1907 .position(|window| window == b"\r\n\r\n")
1908 .map_or(&[], |position| &response[position + 4..])
1909 }
1910
1911 #[tokio::test]
1912 async fn host_http_auth_models_command_and_snapshot_are_hermetic() {
1913 let mut app = test_app();
1914 app.models = vec![crate::provider::Model {
1915 id: "gpt-test".into(),
1916 name: "Test model".into(),
1917 reasoning_efforts: Vec::new(),
1918 context_length: Some(8_192),
1919 supports_images: false,
1920 supports_image_generation: false,
1921 supports_video_generation: false,
1922 backend: BackendTag::OpenAi,
1923 pricing: None,
1924 }];
1925 let mut server = HostServer::bind(app, HostConfig::new(0, "host-secret"))
1926 .await
1927 .unwrap();
1928 let addr = server.local_addr();
1929
1930 let unauthorized = raw_request(addr, request("GET", "/v1/snapshot", None, &[])).await;
1931 assert!(String::from_utf8_lossy(&unauthorized).starts_with("HTTP/1.1 401"));
1932
1933 let models =
1934 raw_request(addr, request("GET", "/v1/models", Some("host-secret"), &[])).await;
1935 assert!(String::from_utf8_lossy(&models).starts_with("HTTP/1.1 200"));
1936 let models: serde_json::Value = serde_json::from_slice(response_body(&models)).unwrap();
1937 assert_eq!(models["object"], "list");
1938 assert_eq!(models["data"][0]["object"], "model");
1939 assert_eq!(models["data"][0]["id"], "openai:gpt-test");
1940 assert_eq!(models["data"][0]["owned_by"], "openai");
1941
1942 let command = serde_json::to_vec(&AppCommand::SetSetting {
1943 key: "langsearch_key".into(),
1944 value: "do-not-leak".into(),
1945 })
1946 .unwrap();
1947 let command_response = raw_request(
1948 addr,
1949 request("POST", "/v1/command", Some("host-secret"), &command),
1950 )
1951 .await;
1952 assert!(String::from_utf8_lossy(&command_response).starts_with("HTTP/1.1 202"));
1953 tokio::time::sleep(Duration::from_millis(20)).await;
1954 let snapshot = raw_request(
1955 addr,
1956 request("GET", "/v1/snapshot", Some("host-secret"), &[]),
1957 )
1958 .await;
1959 let snapshot_text = String::from_utf8_lossy(response_body(&snapshot));
1960 assert!(!snapshot_text.contains("do-not-leak"));
1961 assert!(snapshot_text.contains("langsearch_configured"));
1962
1963 server.shutdown().await;
1964 }
1965
1966 #[tokio::test]
1967 async fn host_gateway_preserves_mocked_stream_bytes() {
1968 let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1969 let mock_addr = listener.local_addr().unwrap();
1970 let expected = br#"data: {"choices":[{"delta":{"content":"hi"}}]}
1971
1972data: [DONE]
1973
1974"#;
1975 let expected_for_task = expected.to_vec();
1976 let mock = tokio::spawn(async move {
1977 let (mut stream, _) = listener.accept().await.unwrap();
1978 let mut request = Vec::new();
1979 loop {
1980 let mut chunk = [0u8; 1024];
1981 let n = stream.read(&mut chunk).await.unwrap();
1982 assert!(n > 0);
1983 request.extend_from_slice(&chunk[..n]);
1984 if request.windows(4).any(|window| window == b"\r\n\r\n") {
1985 break;
1986 }
1987 }
1988 let request_text = String::from_utf8_lossy(&request);
1989 assert!(request_text.starts_with("POST /chat/completions HTTP/1.1"));
1990 assert!(request_text.contains("authorization: Bearer sk-host-test"));
1991 let header = format!(
1992 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n",
1993 expected_for_task.len()
1994 );
1995 stream.write_all(header.as_bytes()).await.unwrap();
1996 stream.write_all(&expected_for_task).await.unwrap();
1997 });
1998
1999 let mut app = test_app();
2000 app.models = vec![crate::provider::Model {
2001 id: "gpt-test".into(),
2002 name: "Test model".into(),
2003 reasoning_efforts: Vec::new(),
2004 context_length: None,
2005 supports_images: false,
2006 supports_image_generation: false,
2007 supports_video_generation: false,
2008 backend: BackendTag::OpenAi,
2009 pricing: None,
2010 }];
2011 let mut server = HostServer::bind(
2012 app,
2013 HostConfig::new(0, "host-secret").with_gateway_base(format!("http://{mock_addr}")),
2014 )
2015 .await
2016 .unwrap();
2017 let body = br#"{"model":"openai:gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}"#;
2018 let response = raw_request(
2019 server.local_addr(),
2020 request("POST", "/v1/chat/completions", Some("host-secret"), body),
2021 )
2022 .await;
2023 assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 200"));
2024 assert!(response.ends_with(expected));
2025 mock.await.unwrap();
2026 server.shutdown().await;
2027 }
2028
2029 #[tokio::test]
2030 async fn host_sse_and_http_blob_transfer_are_hermetic() {
2031 let app = test_app();
2032 let space_id = app.active_space.id.clone();
2033 let name = "remote.txt";
2034 let bytes = b"blob over http";
2035 let digest = sha2::Sha256::digest(bytes);
2036 let hash = digest
2037 .iter()
2038 .map(|byte| format!("{byte:02x}"))
2039 .collect::<String>();
2040 app.db
2041 .upsert_file(&space_id, name, &hash, bytes.len() as i64, "ok")
2042 .unwrap();
2043 let mut server = HostServer::bind(app, HostConfig::new(0, "host-secret"))
2044 .await
2045 .unwrap();
2046 let addr = server.local_addr();
2047
2048 let mut events = TcpStream::connect(addr).await.unwrap();
2049 events
2050 .write_all(&request("GET", "/v1/events", Some("host-secret"), &[]))
2051 .await
2052 .unwrap();
2053 let mut initial = Vec::new();
2054 while !initial.windows(4).any(|window| window == b"\r\n\r\n") {
2055 let mut chunk = [0u8; 1024];
2056 let n = tokio::time::timeout(Duration::from_secs(2), events.read(&mut chunk))
2057 .await
2058 .unwrap()
2059 .unwrap();
2060 assert!(n > 0);
2061 initial.extend_from_slice(&chunk[..n]);
2062 }
2063
2064 let setting = serde_json::to_vec(&AppCommand::Send {
2065 text: "event-secret".into(),
2066 })
2067 .unwrap();
2068 let _ = raw_request(
2069 addr,
2070 request("POST", "/v1/command", Some("host-secret"), &setting),
2071 )
2072 .await;
2073 let mut frame_bytes = initial;
2074 while !String::from_utf8_lossy(&frame_bytes).contains("composer_set") {
2075 let mut chunk = [0u8; 1024];
2076 let n = tokio::time::timeout(Duration::from_secs(2), events.read(&mut chunk))
2077 .await
2078 .unwrap()
2079 .unwrap();
2080 assert!(n > 0);
2081 frame_bytes.extend_from_slice(&chunk[..n]);
2082 }
2083 assert!(!String::from_utf8_lossy(&frame_bytes).contains("host-secret"));
2084 events.shutdown().await.unwrap();
2085
2086 let target = format!("/v1/sync/blob?space_id={space_id}&name={name}&hash={hash}");
2087 let uploaded = raw_request(addr, request("PUT", &target, Some("host-secret"), bytes)).await;
2088 assert!(String::from_utf8_lossy(&uploaded).starts_with("HTTP/1.1 201"));
2089 let downloaded = raw_request(
2090 addr,
2091 request(
2092 "GET",
2093 &format!("/v1/sync/blob?space_id={space_id}&name={name}"),
2094 Some("host-secret"),
2095 &[],
2096 ),
2097 )
2098 .await;
2099 assert_eq!(response_body(&downloaded), bytes);
2100 server.shutdown().await;
2101 }
2102}