From 7aab33a6a6b5df584a590163e2527c3ddf8e6a33 Mon Sep 17 00:00:00 2001 From: l28756 Date: Thu, 29 Oct 2026 18:20:12 +0800 Subject: [PATCH 1/2] fix(tui): widen local-endpoint SSE open timeout to 180s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud-budget 45s header wait exists to fail fast on dead cloud hosts; a local model (loopback/private) legitimately spends minutes before response headers when it prefills a very long prompt cold — pinvou3 field data (TensorFold dual-GPU, 2026-10-29): ~25K-token agent prompt sits silent ~30s at 854 tok/s first-pass. endpoint_is_local() routes loopback/localhost/RFC1918/link-local/ULA base URLs to a 180s budget across all three streaming adapters; explicit env override still wins for both classes. Per-chunk idle stays 900s (already >= the 120s local contract). Signed-off-by: l28756 --- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/client/anthropic.rs | 3 +- crates/tui/src/client/chat.rs | 5 +- crates/tui/src/client/responses.rs | 3 +- crates/tui/src/client/stream_entry.rs | 107 ++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f13a1d4a5f..6874bdbd13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1149,6 +1149,7 @@ dependencies = [ "unicode-normalization", "unicode-segmentation", "unicode-width", + "url", "urlencoding", "uuid", "wait-timeout", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 357c376198..2aa72505b5 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -85,6 +85,7 @@ tokio-util = { version = "0.7.16", features = ["io"] } unicode-width = "0.2" unicode-segmentation = "1.12" unicode-normalization = "0.1" +url = "2" urlencoding = "2.1" uuid.workspace = true chrono.workspace = true diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 68e8b9460f..5501e1e0eb 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -351,7 +351,8 @@ impl DeepSeekClient { ) -> Result { let url = self.messages_transport_url(url); let open_req = super::stream_entry::StreamOpenRequest::new( - super::stream_entry::stream_open_timeout(), + // 本地模型端点按 base_url 放宽(冷 prefill 可静默数十秒),云保持 45s + super::stream_entry::stream_open_timeout_for(&self.base_url), self.stream_idle_timeout, ); let opened = super::stream_entry::open_sse_response(&open_req, |policy| { diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 83d3cbee1c..49446148af 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -25,7 +25,7 @@ use crate::config::{ // The bounded response-header wait (`stream_open_timeout`) and its env // override live in the shared stream-entry seam; every streaming adapter // (Chat Completions / Anthropic Messages / Responses) uses the same policy. -use super::stream_entry::stream_open_timeout; +use super::stream_entry::stream_open_timeout_for; fn stream_idle_timeout_message( idle: Duration, @@ -1343,7 +1343,8 @@ impl DeepSeekClient { body: &Value, ) -> Result<(reqwest::Response, Duration)> { let open_req = super::stream_entry::StreamOpenRequest::new( - stream_open_timeout(), + // 本地模型端点按 base_url 放宽(冷 prefill 可静默数十秒),云保持 45s + stream_open_timeout_for(&self.base_url), self.stream_idle_timeout, ); let idle_timeout = open_req.idle_timeout; diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 16bb4af8c0..3a292c9647 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -266,7 +266,8 @@ impl DeepSeekClient { let request_body = serde_json::to_vec(&body).context("Failed to serialize Responses API request body")?; let open_req = super::stream_entry::StreamOpenRequest::new( - super::stream_entry::stream_open_timeout(), + // 本地模型端点按 base_url 放宽(冷 prefill 可静默数十秒),云保持 45s + super::stream_entry::stream_open_timeout_for(&self.base_url), self.stream_idle_timeout, ); let response = super::stream_entry::open_sse_response(&open_req, |policy| { diff --git a/crates/tui/src/client/stream_entry.rs b/crates/tui/src/client/stream_entry.rs index 125c101540..efa0c4eff2 100644 --- a/crates/tui/src/client/stream_entry.rs +++ b/crates/tui/src/client/stream_entry.rs @@ -41,6 +41,55 @@ pub(crate) fn stream_open_timeout_from_env(value: Option<&str>) -> Duration { Duration::from_secs(secs) } +/// Open-timeout budget for local model endpoints. A model running on this +/// machine (or the LAN) can legitimately spend minutes between TCP accept and +/// response headers when it prefills a very long prompt before emitting the +/// first byte — the 45s cloud budget exists to fail fast on dead cloud hosts, +/// not to cut a local engine mid-prefill. +/// +/// pinvou3 field data (2026-10-29, TensorFold dual-GPU card): a ~25K-token +/// agent system prompt sits silent for ~30s on cold prefill (~854 tok/s +/// first-pass), ~3.3s warm; the dspark family is the same order. 180s covers +/// a full cold context on that class of hardware with headroom. +pub(crate) const LOCAL_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(180); + +/// Resolve the SSE open timeout for a concrete endpoint: loopback/private +/// hosts get [`LOCAL_STREAM_OPEN_TIMEOUT`], everything else keeps the cloud +/// default. An explicit env override (`CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, +/// legacy alias) still wins for **both** classes — it is the escape hatch for +/// either side and must stay observable. +pub(crate) fn stream_open_timeout_for(base_url: &str) -> Duration { + let env_set = std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS") + .or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS")) + .is_ok(); + if !env_set && endpoint_is_local(base_url) { + LOCAL_STREAM_OPEN_TIMEOUT + } else { + stream_open_timeout() + } +} + +/// True when the base URL terminates on this machine or its local network: +/// loopback (v4+v6), `.localhost` names, RFC1918 private, link-local, and +/// unique-local v6. Hostnames that are not `localhost` are conservatively +/// treated as remote — no DNS resolution on this hot path, and a cloud host +/// behind a resolvable name would be misclassified far more expensively in +/// the other direction. +fn endpoint_is_local(base_url: &str) -> bool { + let Some(parsed) = url::Url::parse(base_url).ok() else { + return false; + }; + match parsed.host() { + Some(url::Host::Domain(host)) => { + host.eq_ignore_ascii_case("localhost") + || host.to_ascii_lowercase().ends_with(".localhost") + } + Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unique_local(), + None => false, + } +} + /// How the shared stream open path should pin HTTP version. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamHttpPolicy { @@ -253,6 +302,64 @@ mod tests { } } + /// pinvou3 实测(2026-10-29 TensorFold 双机卡):25K-token 系统提示词冷 + /// prefill 静默 ~30s 才出首字节——本地端点的头等待必须显著宽于云预算。 + /// env 是进程级状态,两个涉及 env 的测试用同一把锁串行。 + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn local_endpoints_get_the_wider_open_budget() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // 本地各形态:回环/私网/链路本地/localhost 名(含大小写) + for url in [ + "http://127.0.0.1:8889/v1", + "http://localhost:8080/v1", + "http://LOCALHOST:8080", + "http://127.0.0.1/v1", + "http://192.168.10.10:8888/v1", + "http://10.0.0.5:11434/v1", + "http://172.16.3.4/v1", + "http://169.254.7.7/v1", + "http://[::1]:9000/v1", + "http://[fd00::1]:9000/v1", + ] { + assert!(endpoint_is_local(url), "应识别为本地端点:{url}"); + assert_eq!( + stream_open_timeout_for(url), + LOCAL_STREAM_OPEN_TIMEOUT, + "本地端点应拿宽预算:{url}" + ); + } + // 云端与不可解析输入维持默认/保守 + for url in [ + "https://api.deepseek.com/v1", + "https://gateway.example.com/v1", + "https://my-model.internal.example.com", // 可解析域名保守按远端 + "", + "not a url", + ] { + assert!(!endpoint_is_local(url), "不该判本地:{url:?}"); + assert_eq!( + stream_open_timeout_for(url), + DEFAULT_STREAM_OPEN_TIMEOUT, + "远端应维持 45s:{url:?}" + ); + } + } + + #[test] + fn env_override_wins_over_the_local_budget() { + // 环境变量是两侧共用的逃生口:显式设置后本地/远端都听它的。 + // set/remove_var 在 edition 2024 是 unsafe:进程内其他线程可能并发 + // 读 env——本测试与上面那个都持有 ENV_LOCK,且两个调用之间没有 + // await 点,实际单线程访问。 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { std::env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "66") }; + let r = stream_open_timeout_for("http://127.0.0.1:8889/v1"); + unsafe { std::env::remove_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS") }; + assert_eq!(r, Duration::from_secs(66)); + } + async fn ok_server() -> MockServer { let server = MockServer::start().await; Mock::given(method("POST")) From 0f792f7c9e71404af7c4a97f0ae1eca00a8ae98b Mon Sep 17 00:00:00 2001 From: l28756 Date: Mon, 2 Nov 2026 19:26:24 +0800 Subject: [PATCH 2/2] fix(tui): widen local classification + hermetic tests (review minors) Signed-off-by: l28756 --- crates/tui/src/client/stream_entry.rs | 59 ++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/client/stream_entry.rs b/crates/tui/src/client/stream_entry.rs index efa0c4eff2..4e7d3403f7 100644 --- a/crates/tui/src/client/stream_entry.rs +++ b/crates/tui/src/client/stream_entry.rs @@ -70,11 +70,16 @@ pub(crate) fn stream_open_timeout_for(base_url: &str) -> Duration { } /// True when the base URL terminates on this machine or its local network: -/// loopback (v4+v6), `.localhost` names, RFC1918 private, link-local, and -/// unique-local v6. Hostnames that are not `localhost` are conservatively -/// treated as remote — no DNS resolution on this hot path, and a cloud host -/// behind a resolvable name would be misclassified far more expensively in -/// the other direction. +/// loopback (v4+v6), `.localhost` names, RFC1918 private, link-local (v4+v6), +/// unique-local v6, IPv4-mapped v6 (re-checked as v4), and CGNAT +/// 100.64.0.0/10 — the range Tailscale-style overlays use, a very common way +/// to reach a home/office GPU box (`validate_base_url_security` already +/// invites llama.cpp-on-private-IP over HTTP, so those endpoints are in +/// scope). Hostnames that are not `localhost` are conservatively treated as +/// remote — no DNS resolution on this hot path, and a cloud host behind a +/// resolvable name would be misclassified far more expensively in the other +/// direction. `0.0.0.0` as a *destination* stays remote: nothing should +/// dial it, and widening for it would only mask caller bugs. fn endpoint_is_local(base_url: &str) -> bool { let Some(parsed) = url::Url::parse(base_url).ok() else { return false; @@ -84,12 +89,27 @@ fn endpoint_is_local(base_url: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host.to_ascii_lowercase().ends_with(".localhost") } - Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), - Some(url::Host::Ipv6(ip)) => ip.is_loopback() || ip.is_unique_local(), + Some(url::Host::Ipv4(ip)) => ipv4_is_local(ip), + Some(url::Host::Ipv6(ip)) => { + ip.is_loopback() + || ip.is_unique_local() + || ip.is_unicast_link_local() + // `[::ffff:127.0.0.1]`-style mapped addresses classify by + // their v4 payload, not by the v6 wrapper. + || ip.to_ipv4_mapped().is_some_and(ipv4_is_local) + } None => false, } } +fn ipv4_is_local(ip: std::net::Ipv4Addr) -> bool { + // 100.64.0.0/10(CGNAT,Tailscale 类 overlay):is_shared() 在本工具链 + // 还是 unstable(feature ip),手写第二字节 64..=127 判定。 + let o = ip.octets(); + let cgnat = o[0] == 100 && (64..=127).contains(&o[1]); + ip.is_loopback() || ip.is_private() || ip.is_link_local() || cgnat +} + /// How the shared stream open path should pin HTTP version. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamHttpPolicy { @@ -310,7 +330,13 @@ mod tests { #[test] fn local_endpoints_get_the_wider_open_budget() { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // 本地各形态:回环/私网/链路本地/localhost 名(含大小写) + // 评审 MINOR:宿主机可能 export 了超时变量——不清掉的话 + // `== LOCAL_STREAM_OPEN_TIMEOUT` 断言在那种机器上必红。持锁清理后 + // 本测试对环境完全密封。 + unsafe { std::env::remove_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS") }; + unsafe { std::env::remove_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS") }; + // 本地各形态:回环/私网/链路本地/localhost 名(含大小写)、v6 链路本地、 + // IPv4-mapped v6、CGNAT(Tailscale 类)——后三类是评审 MINOR 补的口。 for url in [ "http://127.0.0.1:8889/v1", "http://localhost:8080/v1", @@ -322,6 +348,9 @@ mod tests { "http://169.254.7.7/v1", "http://[::1]:9000/v1", "http://[fd00::1]:9000/v1", + "http://[fe80::1]:9000/v1", + "http://[::ffff:127.0.0.1]:9000/v1", + "http://100.101.102.103:8000/v1", ] { assert!(endpoint_is_local(url), "应识别为本地端点:{url}"); assert_eq!( @@ -330,11 +359,13 @@ mod tests { "本地端点应拿宽预算:{url}" ); } - // 云端与不可解析输入维持默认/保守 + // 云端与不可解析输入维持默认/保守。0.0.0.0 作为目的地属调用方 + // bug,刻意不放宽(见 endpoint_is_local 注释)。 for url in [ "https://api.deepseek.com/v1", "https://gateway.example.com/v1", "https://my-model.internal.example.com", // 可解析域名保守按远端 + "http://0.0.0.0:8888/v1", "", "not a url", ] { @@ -353,10 +384,18 @@ mod tests { // set/remove_var 在 edition 2024 是 unsafe:进程内其他线程可能并发 // 读 env——本测试与上面那个都持有 ENV_LOCK,且两个调用之间没有 // await 点,实际单线程访问。 + // 评审 MINOR:断言 panic 时变量会泄进进程毒化其他测试——用 Drop + // 守卫保证无论怎么退出都清理。 + struct EnvReset; + impl Drop for EnvReset { + fn drop(&mut self) { + unsafe { std::env::remove_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS") }; + } + } let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let _reset = EnvReset; unsafe { std::env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "66") }; let r = stream_open_timeout_for("http://127.0.0.1:8889/v1"); - unsafe { std::env::remove_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS") }; assert_eq!(r, Duration::from_secs(66)); }