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));
}