From 6a288512ca07923b28067992ef33a6ce46903ffd 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 4a5dd208b3f1e7821356b38671b76a9ecf02cd79 Mon Sep 17 00:00:00 2001 From: l28756 Date: Mon, 2 Nov 2026 10:35:53 +0800 Subject: [PATCH 2/2] feat(tui): default agent-turn temperature to 0.3 Signed-off-by: l28756 --- crates/tui/src/acp_server.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs index 535d748744..4a487f1950 100644 --- a/crates/tui/src/acp_server.rs +++ b/crates/tui/src/acp_server.rs @@ -2016,7 +2016,13 @@ impl AcpServer { thinking: None, reasoning_effort, stream: Some(true), - temperature: None, + // Agent 回合的默认采样温度(2026-11-02 拍板):不显式下发时多数推理 + // 服务端按 temperature=1.0 处理——随机性最大,还略降 MTP 类引擎的 + // 投机接受率(TensorFold 10-30 性能报告②)。Agent 用途需要稳定输出, + // 0.3 兼顾确定性与多样性。副作用说明:本地引擎卡片的 LMM_TEMPERATURE + // 旋钮改的是服务端缺省,显式请求值会覆盖它——需要引擎侧自定义采样时 + // 在模型配置层显式设置(未来引入用户级采样配置后由用户值优先)。 + temperature: Some(DEFAULT_AGENT_TEMPERATURE), top_p: None, }; @@ -2024,6 +2030,10 @@ impl AcpServer { } } +/// Agent 回合未显式配置采样时的下发温度(2026-11-02 拍板值 0.3)。理由与 +/// 副作用见请求构造处的注释。 +const DEFAULT_AGENT_TEMPERATURE: f32 = 0.3; + fn resolve_acp_route_facts( config: &Config, provider: ApiProvider, @@ -2633,6 +2643,15 @@ impl JsonRpcResponseIdPolicy { #[cfg(test)] mod tests { + /// 2026-11-02 拍板:Agent 回合默认下发 temperature=0.3(不显式带采样参数时 + /// 服务端按 1.0 处理——随机性最大且略降 MTP 接受率)。钉住拍板值防漂移; + /// 序列化契约(Some(t) → body.temperature)由 client 侧 Kimi 固定采样 + /// 用例覆盖。 + #[test] + fn agent_turn_default_temperature_is_the_ratified_value() { + assert_eq!(DEFAULT_AGENT_TEMPERATURE, 0.3); + } + use super::*; use std::cell::RefCell; use std::collections::VecDeque;