From c1e4994219fbb259619b629bc1ce0e22164abe85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 15:00:31 +0800 Subject: [PATCH] Cloud brain uses a model on the person's network through their computer; the chosen computer is where a bot works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A brain that can't reach a model at a private address (e.g. Qwen at 10.x) sends the request through its hands; the hands only forward to addresses on their own network, and only for their brain (uses the existing device channel, so the account server is unchanged). - The computer chosen for a bot is binding: a step naming another computer goes to the chosen one unless the person's message names that one (「在我的 Mac 上」). - Browser steps follow the computer: Bot 的電腦 drives the Chromium on its own desktop (through docker exec, no new port), 你的電腦 its browser; before, they always used this computer's. Checked: on this Mac, Bot 的電腦 browsing opened example.com in the box's Chromium and an unasked 你的電腦 command ran in the box; 「在我的 Mac 上」 ran on the Mac. Linux brain in Docker with 10.0.0.0/8 blocked + this Mac as hands: model calls went through the Mac, shell on 你的電腦 answered from the Mac, browser defaulted to the Mac's. Co-Authored-By: Claude Opus 5.5 --- src/agent.rs | 71 ++++++++++++++++++++++++++++++-- src/app.rs | 4 +- src/botbrowser.rs | 33 +++++++++++++++ src/cloudbox.rs | 67 ++++++++++++++++++++++++++++++ src/providers.rs | 60 ++++++++++++++++++++------- src/remote.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 318 insertions(+), 20 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 3943af0..ad13462 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -114,6 +114,65 @@ fn target_for(job: &Job, args: &str) -> Result { }) } +enum BrowserAt { + Here, + Box, + Device(String), +} + +/// Whose browser a browser step uses: the computer it names, else the bot's chosen computer. +fn browser_place(job: &Job, name: &str, args: &str) -> BrowserAt { + if name == "web_fetch" { + return BrowserAt::Here; + } + let v = tools::args_of(args); + let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); + let any_device = || job.computers.iter().find_map(|t| match &t.place { + tools::Place::Device { host } => Some(host.clone()), + _ => None, + }); + match job.computers.iter().find(|t| t.name == want).map(|t| &t.place) { + Some(tools::Place::Device { host }) => BrowserAt::Device(host.clone()), + Some(tools::Place::Box { .. }) if crate::cloudbox::available() => BrowserAt::Box, + // No browser here (a brain on a server): the 手腳 computer's. + _ if !crate::botbrowser::available() => any_device().map(BrowserAt::Device).unwrap_or(BrowserAt::Here), + _ => BrowserAt::Here, + } +} + +/// Words that mean a computer in the person's message. +fn mentions(text: &str, t: &tools::Target) -> bool { + let low = text.to_lowercase(); + if low.contains(&t.name.to_lowercase()) { + return true; + } + let words: &[&str] = match &t.place { + _ if t.name == crate::model::BOT_COMPUTER_NAME => &["bot 的電腦", "bot的電腦", "你自己的電腦", "你的虛擬", "虛擬電腦", "雲端電腦"], + _ if t.name == crate::model::THIS_COMPUTER_NAME => &["mac", "我的電腦", "我電腦", "我這台", "這台電腦", "本機", "我的筆電"], + tools::Place::Device { host } => return !crate::remote::peer_name(host).is_empty() && low.contains(&crate::remote::peer_name(host).to_lowercase()), + _ => &[], + }; + words.iter().any(|w| low.contains(w)) +} + +/// A step naming a computer other than the chosen one, which the person didn't ask for, goes +/// to the chosen one. +fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> String { + let mut v = tools::args_of(args); + let Some(named) = v.get("computer").and_then(|c| c.as_str()).map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) else { return args.to_string() }; + if named == job.default_computer || !job.computers.iter().any(|t| t.name == job.default_computer) { + return args.to_string(); + } + let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::>().join("\n"); + match job.computers.iter().find(|t| t.name == named) { + Some(t) if mentions(&said, t) => args.to_string(), + _ => { + v["computer"] = serde_json::Value::String(job.default_computer.clone()); + v.to_string() + } + } +} + /// Does the call name a computer? fn names_a_computer(args: &str) -> bool { tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false) @@ -288,6 +347,10 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if cancel.load(Ordering::Relaxed) { return; } + let mut out = out; + for c in out.tool_calls.iter_mut() { + c.args = keep_to_chosen(&job, &history, &c.args); + } let msg = Message { role: Role::Assistant, text: crate::i18n::reply_text(&out.text), @@ -309,6 +372,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< } continue; } + // (Each call's computer was already kept to the chosen one, see keep_to_chosen.) for call in calls { if appctl::is_app_tool(&call.name) { // App actions run on the window's side, which also asks for deletions. @@ -347,12 +411,13 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if call.name == "web_fetch" || (web::is_web_tool(&call.name) && !web::uses_this_computer(&call.name, &call.args)) { send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Running)); // 裝置連結: the AnyBot browser of another computer (its own logins). - let (output, ok, img) = match device_named(&job, &call.args).filter(|_| call.name != "web_fetch") { - Some(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { + let (output, ok, img) = match browser_place(&job, &call.name, &call.args) { + BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) { Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)), Err(e) => (e, false, None), }, - None => web::run_full(&call.name, &call.args, &cancel), + BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)), + BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel), }; send(StreamEvent::ToolStatus(call.id.clone(), if ok { ToolStatus::Done } else { ToolStatus::Failed })); let mut result = Message::tool_result(&call.id, &call.name, &output); diff --git a/src/app.rs b/src/app.rs index 8764002..1064159 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3722,7 +3722,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu ), tools::Place::Ssh { target, .. } => format!("- {}:使用者的另一台電腦(SSH {target})", t.name), tools::Place::Device { host } if t.name == crate::model::THIS_COMPUTER_NAME => format!( - "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的 Mac」「我的電腦」「這台」都是指它。", + "- {}:使用者自己的電腦「{}」(你在伺服器上,透過它的 AnyBot 操作它:檔案、指令、瀏覽器和 computer_use_* 工具,computer=「{}」)。使用者說「我的電腦」「我的筆電」「這台」或提到 Mac,都是指它。", t.name, crate::remote::peer_name(host), t.name @@ -3732,7 +3732,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu .collect(); s.push_str(&format!( "\n\n你可以用工具在電腦上做事。每個工具都有 computer 參數,預設是「{default_computer}」。\n{}\n\ - - 使用者說在哪台電腦上做(「在我的 Mac 上」「在工作站上」),就把 computer 設成那台;沒說就用預設。\n\ + - 使用者替你選的電腦是「{default_computer}」:檔案、指令、看畫面、點擊和瀏覽器都在這台做,不要自己換到別台。只有使用者在訊息裡指名另一台(「在我的 Mac 上」「在 Bot 的電腦上」),才把 computer 設成那台。\n\ - 先用 list_dir、search、read_file 看清楚,再動手。\n\ - 需要權限時(電腦工具還沒打開、要用使用者的電腦、會改變東西的操作),直接呼叫工具,App 會自己跳出視窗請使用者允許;不要叫使用者自己去改設定。被拒絕就換個方式或問使用者。\n\ - 跑很久的指令會移到背景,用 read_terminal 追蹤,不要重複執行;它在等輸入時(例如問 y/n),用 write_terminal 回答。\n\ diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 3c78256..bddd3bf 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -61,6 +61,9 @@ pub fn engine() -> Option<(&'static str, &'static str)> { } pub fn available() -> bool { + if using_box() { + return true; + } engine().is_some() } @@ -132,6 +135,9 @@ pub fn match_profile(list: &[String], want: &str) -> Option { /// Which profile a tool call means: the one named, or the current one. pub fn resolve(requested: Option<&str>) -> Result { + if using_box() { + return Ok(DEFAULT_PROFILE.to_string()); + } match requested.map(str::trim).filter(|s| !s.is_empty()) { None => Ok(current()), Some(r) => { @@ -231,8 +237,35 @@ pub fn encode_component(s: &str) -> String { .collect() } +thread_local! { + /// Browser steps on this thread go to Bot 的電腦's Chromium (its box), not this computer's. + static IN_BOX: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `f` with the browser steps on Bot 的電腦's own Chromium. +pub fn in_box(f: impl FnOnce() -> T) -> T { + IN_BOX.with(|b| b.set(true)); + let r = f(); + IN_BOX.with(|b| b.set(false)); + r +} + +pub fn using_box() -> bool { + IN_BOX.with(|b| b.get()) +} + /// Start this profile's window if it isn't running. Returns (browser name, port, just started). pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> { + if using_box() { + let fresh = !crate::cloudbox::browser_running(); + let port = crate::cloudbox::browser_port()?; + if fresh { + if let Some(u) = first_url { + let _ = local().put(&endpoint(port, &format!("/json/new?{}", encode_component(u)))).send_empty(); + } + } + return Ok(("Bot 的電腦的 Chromium", port, fresh && first_url.is_some())); + } let Some((name, bin)) = engine() else { return Err("這台電腦沒有 Chrome、Brave、Edge 或 Chromium,沒辦法開 AnyBot 自己的瀏覽器。".into()) }; if profile == PERSON_PROFILE { return Err("「你的瀏覽器」是你自己的視窗,AnyBot 不會另外開。".into()); diff --git a/src/cloudbox.rs b/src/cloudbox.rs index cccb5bd..25befcb 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -541,6 +541,73 @@ fn wait_ready(ms: u64) -> Result<(), String> { Err("電腦開機逾時".into()) } +// ---------- the box's own browser ---------- +// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the +// box's window). Its DevTools port stays inside the box; a local port reaches it through +// `docker exec` (no published port, so it also works with a Docker host elsewhere). + +const BOX_CDP_PORT: u16 = 9222; + +fn box_cdp_alive(local: u16) -> bool { + crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false) +} + +/// A local port that reaches the box's DevTools port. +fn box_cdp_forward() -> Result { + static PORT: Mutex = Mutex::new(0); + let mut p = PORT.lock().unwrap(); + if *p != 0 { + return Ok(*p); + } + let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; + let port = l.local_addr().map_err(|e| e.to_string())?.port(); + std::thread::spawn(move || { + for conn in l.incoming().flatten() { + std::thread::spawn(move || { + let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null"); + let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; + let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return }; + let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; + let up = std::thread::spawn(move || { + let _ = std::io::copy(&mut r, &mut cin); + }); + let _ = std::io::copy(&mut cout, &mut w); + let _ = conn.shutdown(std::net::Shutdown::Both); + let _ = child.kill(); + let _ = up.join(); + let _ = child.wait(); + }); + } + }); + *p = port; + Ok(port) +} + +/// Is the box's Chromium answering already? +pub fn browser_running() -> bool { + box_cdp_forward().map(box_cdp_alive).unwrap_or(false) +} + +/// The box's Chromium, started on its desktop when it isn't running. Gives the local port. +pub fn browser_port() -> Result { + ensure_running()?; + touch(); + let port = box_cdp_forward()?; + if box_cdp_alive(port) { + return Ok(port); + } + let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}"); + dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(20) { + if box_cdp_alive(port) { + return Ok(port); + } + std::thread::sleep(Duration::from_millis(300)); + } + Err("Bot 的電腦的瀏覽器沒有及時打開,請再試一次。".into()) +} + static START_LOCK: Mutex<()> = Mutex::new(()); /// Make sure the box is up, waking or creating it as needed. Blocks until it's ready. diff --git a/src/providers.rs b/src/providers.rs index f169b95..43e3fa1 100644 --- a/src/providers.rs +++ b/src/providers.rs @@ -414,8 +414,12 @@ pub fn run_step(req: &ChatRequest, cancel: &AtomicBool, send: &dyn Fn(StreamEven } } -fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> { - let reader = BufReader::new(body.into_reader()); +fn read_sse(body: ureq::Body, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> { + read_sse_from(body.into_reader(), cancel, send, on_data) +} + +fn read_sse_from(r: impl std::io::Read, cancel: &AtomicBool, send: &dyn Fn(StreamEvent), mut on_data: impl FnMut(&str, &str) -> bool) -> Result<(), Failure> { + let reader = BufReader::new(r); let mut event = String::new(); for line in reader.lines() { if cancel.load(Ordering::Relaxed) { @@ -750,29 +754,55 @@ fn stream_openai_compatible(req: &ChatRequest, cancel: &AtomicBool, send: &dyn F body["tools"] = json!(req.tools.iter().map(|t| json!({"type": "function", "function": t})).collect::>()); } let url = format!("{}/chat/completions", req.base_url); - let mut r = agent().post(&url).header("Content-Type", "application/json"); + let mut headers: Vec<(String, String)> = vec![]; if !req.api_key.is_empty() { - r = r.header("Authorization", &format!("Bearer {}", req.api_key)); + headers.push(("Authorization".into(), format!("Bearer {}", req.api_key))); } if req.kind == ProviderKind::OpenRouter { - r = r.header("X-Title", "AnyBot"); + headers.push(("X-Title".into(), "AnyBot".into())); } if req.kind == ProviderKind::OpenCodeGo { - r = r.header("x-opencode-session", &opencode_session(req)); - } - // SAND-E0418 turnBackendUnreachable: retryable. - let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?; - let status = resp.status().as_u16(); - if status >= 400 { - let ra = retry_after_ms(&resp); - let text = resp.into_body().read_to_string().unwrap_or_default(); - return Err(http_error(status, &text, ra)); + headers.push(("x-opencode-session".into(), opencode_session(req))); } + // A brain on a server, a model on the person's own network: through their computer (the + // whole answer comes back at once, so no word-by-word streaming). + let via_hands: Option> = match crate::remote::model_hands_for(&url) { + Some(hand) => { + let (status, bytes) = crate::remote::model_via_hands(&hand, &url, &headers, &body.to_string()).map_err(Failure::retry)?; + if status >= 400 { + return Err(http_error(status, &String::from_utf8_lossy(&bytes), None)); + } + Some(bytes) + } + None => None, + }; + let resp = match via_hands { + Some(_) => None, + None => { + let mut r = agent().post(&url).header("Content-Type", "application/json"); + for (k, v) in &headers { + r = r.header(k, v); + } + // SAND-E0418 turnBackendUnreachable: retryable. + let resp = r.send_json(&body).map_err(|e| Failure::retry(connect_error(req.kind, &e.to_string())))?; + let status = resp.status().as_u16(); + if status >= 400 { + let ra = retry_after_ms(&resp); + let text = resp.into_body().read_to_string().unwrap_or_default(); + return Err(http_error(status, &text, ra)); + } + Some(resp) + } + }; let mut out = StepOutput::default(); // Tool calls arrive in pieces keyed by index. let mut pending: Vec<(String, String, String)> = vec![]; let mut error = None; - read_sse(resp.into_body(), cancel, send, |_ev, data| { + let reader: Box = match (resp, via_hands) { + (Some(r), _) => Box::new(r.into_body().into_reader()), + (None, b) => Box::new(std::io::Cursor::new(b.unwrap_or_default())), + }; + read_sse_from(reader, cancel, send, |_ev, data| { if data == "[DONE]" { return false; } diff --git a/src/remote.rs b/src/remote.rs index 3c2814f..37c9ea3 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -619,6 +619,10 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { let tool = v["tool"].as_str().unwrap_or(""); let args = v["args"].as_str().unwrap_or("{}"); let who = v["who"].as_str().unwrap_or("遠端 AnyBot"); + // The brain's model request to a model only this computer's network reaches. + if tool == MODEL_FETCH { + return model_fetch_here(args); + } if crate::computer::is_computer_tool(tool) { let (text, ok, img) = crate::computer::run_by(tool, args, who); return Resp::json(&json!({"text": text, "ok": ok, "image": img})); @@ -645,6 +649,96 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp { Resp::error(400, "這個工具不能遠端使用") } +// ---------- a model on the person's network, for a brain on a server ---------- +// A brain in the cloud can't reach a model at a private address (a Qwen server at work, Ollama at +// home). Its hands can: the brain hands them the request and gets the whole answer back. + +const MODEL_FETCH: &str = "model_fetch"; + +/// Private, link-local or loopback address (by name: localhost and *.local). +fn private_host(host: &str) -> bool { + let h = host.trim_matches(['[', ']']); + if h == "localhost" || h.ends_with(".local") { + return true; + } + match h.parse::() { + Ok(std::net::IpAddr::V4(a)) => a.is_private() || a.is_loopback() || a.is_link_local(), + Ok(std::net::IpAddr::V6(a)) => a.is_loopback() || (a.segments()[0] & 0xfe00) == 0xfc00, + Err(_) => false, + } +} + +fn host_port(url: &str) -> Option<(String, u16)> { + let (scheme, rest) = url.split_once("://")?; + let auth = rest.split('/').next()?; + let auth = auth.rsplit('@').next()?; + let (h, p) = match auth.rsplit_once(':') { + Some((h, p)) if !h.ends_with(']') || auth.starts_with('[') => (h.to_string(), p.parse().ok()?), + _ => (auth.to_string(), if scheme == "https" { 443 } else { 80 }), + }; + Some((h, p)) +} + +/// The hands to send a model request through: the model is at a private address this brain +/// can't open a connection to, and the person's computer lends this brain its hands. +pub fn model_hands_for(url: &str) -> Option { + let s = hub().settings(); + let hand = my_hands(&s.host_id).into_iter().next()?; + let (h, p) = host_port(url)?; + if !private_host(&h) || h == "localhost" || h.starts_with("127.") { + return None; + } + static SEEN: Mutex>> = Mutex::new(None); + let key = format!("{h}:{p}"); + if let Some((reach, at)) = SEEN.lock().unwrap().get_or_insert_with(HashMap::new).get(&key) { + if at.elapsed().as_secs() < 300 { + return if *reach { None } else { Some(hand) }; + } + } + use std::net::ToSocketAddrs; + let reach = (h.as_str(), p).to_socket_addrs().ok().and_then(|mut a| a.next()).map(|a| std::net::TcpStream::connect_timeout(&a, std::time::Duration::from_millis(1500)).is_ok()).unwrap_or(false); + SEEN.lock().unwrap().get_or_insert_with(HashMap::new).insert(key, (reach, std::time::Instant::now())); + if reach { None } else { Some(hand) } +} + +/// Send a model request (POST, JSON) through the hands; gives back its status and whole body. +pub fn model_via_hands(hand: &str, url: &str, headers: &[(String, String)], body: &str) -> Result<(u16, Vec), String> { + let args = json!({"url": url, "headers": headers, "body": body}).to_string(); + let req = json!({"tool": MODEL_FETCH, "args": args, "who": "大腦"}).to_string(); + let name = peer_name(hand); + let whom = if name.is_empty() { "你的電腦".to_string() } else { format!("「{name}」") }; + let r = crate::tunnel::call(hand, "POST", "/api/device/run", &[], req.as_bytes(), 600).ok_or_else(|| format!("模型在只有{whom}連得到的網路上,但大腦現在和帳號伺服器沒有連線"))?; + let a = r.map_err(|e| format!("透過{whom}連模型失敗:{e}"))?; + let v = answer_value(a.status, &a.body, &whom)?; + if let Some(e) = v["error"].as_str() { + return Err(format!("{whom}也連不到模型:{e}")); + } + Ok((v["status"].as_u64().unwrap_or(502) as u16, v["text"].as_str().unwrap_or("").as_bytes().to_vec())) +} + +/// On the hands: make the brain's model request, only to an address on this computer's network. +fn model_fetch_here(args: &str) -> Resp { + let v: Value = serde_json::from_str(args).unwrap_or(Value::Null); + let url = v["url"].as_str().unwrap_or(""); + if !host_port(url).map(|(h, _)| private_host(&h)).unwrap_or(false) { + return Resp::error(403, "只幫大腦連這台電腦網路裡的模型"); + } + let mut r = agent(600).post(url).header("Content-Type", "application/json"); + for h in v["headers"].as_array().into_iter().flatten() { + if let (Some(k), Some(val)) = (h[0].as_str(), h[1].as_str()) { + r = r.header(k, val); + } + } + match r.config().http_status_as_error(false).build().send(v["body"].as_str().unwrap_or("").as_bytes()) { + Ok(mut resp) => { + let status = resp.status().as_u16(); + let text = resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_string().unwrap_or_default(); + Resp::json(&json!({"status": status, "text": text})) + } + Err(e) => Resp::json(&json!({"error": why_unreachable(&e)})), + } +} + // ---------- the local server ---------- static LAN_GEN: AtomicU64 = AtomicU64::new(0); @@ -1273,6 +1367,15 @@ pub fn page() -> Resp { mod tests { use super::*; + #[test] + fn model_requests_go_only_to_private_addresses() { + assert!(private_host("10.184.10.252") && private_host("192.168.1.5") && private_host("localhost") && private_host("nas.local")); + assert!(!private_host("api.x.ai") && !private_host("8.8.8.8")); + assert_eq!(host_port("http://10.184.10.252:8000/v1"), Some(("10.184.10.252".into(), 8000))); + assert_eq!(host_port("https://api.x.ai/v1"), Some(("api.x.ai".into(), 443))); + assert_eq!(model_fetch_here(&json!({"url": "https://api.x.ai/v1/chat/completions"}).to_string()).status, 403); + } + #[test] fn phone_login_and_api() { let mut s = Settings { password: httpd::hash_password("pw-123"), ..Default::default() };