Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion crates/tui/src/acp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2016,14 +2016,24 @@ 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,
};

client.create_message_stream(request).await
}
}

/// Agent 回合未显式配置采样时的下发温度(2026-11-02 拍板值 0.3)。理由与
/// 副作用见请求构造处的注释。
const DEFAULT_AGENT_TEMPERATURE: f32 = 0.3;

fn resolve_acp_route_facts(
config: &Config,
provider: ApiProvider,
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,8 @@ impl DeepSeekClient {
) -> Result<reqwest::Response> {
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| {
Expand Down
5 changes: 3 additions & 2 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/client/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
107 changes: 107 additions & 0 deletions crates/tui/src/client/stream_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down
Loading