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;