Merge pull request #623 from H-Chris233/fix/crash-guards-llm-endpoint-url-asr-model-validation

fix: crash guards for LLM endpoint URL + ASR model loading
This commit is contained in:
Chris233 authored and GitHub committed 2026-06-09 20:06:40 +08:00
commit bba2c90ef2
3 files changed
+100 -10

No files matched your search

@@ -47,6 +47,43 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display());
}
// ── 模型文件完整性检查 ────────────────────────────────────────────
// 在调 C FFI 之前先检查关键文件是否齐全、尺寸是否合理,避免因下载不完整
// 或文件损坏导致 C 端 qwen_load / qwen_transcribe_audio segfault 杀死进程。
// 需要的文件清单见 QwenAsrEngine::load() 注释。
let required_files = ["config.json", "vocab.json", "merges.txt"];
for fname in &required_files {
let path = dir.join(fname);
if !path.exists() {
anyhow::bail!("模型文件缺失:{fname},请重新下载(预期路径:{})", path.display());
}
let meta = std::fs::metadata(&path)
.map_err(|e| anyhow::anyhow!("读取 {fname} 元数据失败:{e}"))?;
if meta.len() == 0 {
anyhow::bail!("模型文件为空:{fname},请重新下载");
}
}
// safetensors 可能是单文件 model.safetensors 或分片 model-00001-of-NNNN.safetensors
let has_safetensors: Vec<_> = std::fs::read_dir(&dir)
.map_err(|e| anyhow::anyhow!("读取模型目录失败:{e}"))?
.filter_map(|entry| entry.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "safetensors"))
.collect();
if has_safetensors.is_empty() {
anyhow::bail!("模型目录中没有 .safetensors 权重文件,请重新下载");
}
for entry in &has_safetensors {
let meta = std::fs::metadata(entry.path())
.map_err(|e| anyhow::anyhow!("读取 {} 元数据失败:{e}", entry.path().display()))?;
if meta.len() < 1024 {
anyhow::bail!(
"权重文件太小({} bytes):{},请重新下载",
meta.len(),
entry.path().display()
);
}
}
let samples = decode_wav_16k_mono(TEST_WAV)?;
let audio_ms = (samples.len() as u64) * 1000 / 16_000;
@@ -135,12 +135,19 @@ where
);
return StreamingPolishOutcome::UnsupportedFallback;
}
let provider = match build_active_llm_provider(llm_thinking_enabled) {
Ok(p) => p,
Err(e) => {
let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
build_active_llm_provider(llm_thinking_enabled)
})) {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
log::error!("[coord] streaming polish: build provider failed: {e}");
return StreamingPolishOutcome::Failed(e.to_string());
}
Err(panic) => {
let msg = format!("build_active_llm_provider panicked: {:?}", panic);
log::error!("[coord] {msg}");
return StreamingPolishOutcome::Failed(msg);
}
};
if !provider.supports_streaming_polish() {
log::info!(
@@ -259,7 +266,17 @@ pub(crate) async fn polish_text(
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
build_active_llm_provider(llm_thinking_enabled)
})) {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(e),
Err(panic) => {
let msg = format!("build_active_llm_provider panicked: {:?}", panic);
log::error!("[coord] {msg}");
anyhow::bail!(msg);
}
};
Ok(provider
.polish(
raw,
@@ -305,7 +322,17 @@ pub(crate) async fn translate_text(
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
build_active_llm_provider(llm_thinking_enabled)
})) {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(e),
Err(panic) => {
let msg = format!("build_active_llm_provider panicked: {:?}", panic);
log::error!("[coord] {msg}");
anyhow::bail!(msg);
}
};
Ok(provider
.translate_to(
raw,
@@ -597,7 +624,17 @@ where
.await?);
}
let provider = build_active_llm_provider(llm_thinking_enabled)?;
let provider = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
build_active_llm_provider(llm_thinking_enabled)
})) {
Ok(Ok(p)) => p,
Ok(Err(e)) => return Err(e),
Err(panic) => {
let msg = format!("build_active_llm_provider panicked: {:?}", panic);
log::error!("[coord] {msg}");
anyhow::bail!(msg);
}
};
Ok(provider
.answer_chat_streaming(
messages,
@@ -687,8 +724,17 @@ pub(crate) fn resolve_ark_endpoint_with_policy(
if api_key.trim().is_empty() && endpoint.is_none() {
anyhow::bail!("API Key 为空");
}
let resolved = endpoint
let mut resolved = endpoint
.unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string());
// 兜底:用户可能在自定义 endpoint 时只写了 "192.168.1.100:8080/v1" 漏了 scheme,
// 被 reqwest::IntoUrl 自动补 https:// 后对纯 HTTP 的 llama.cpp 发请求会 TLS 握手失败。
// 检测到无 scheme 时自动补 http://(局域网自托管服务最常见的形态)。
// 用 to_ascii_lowercase 防用户输大写如 HTTP://... 时被误判为缺 scheme。
let lower = resolved.to_ascii_lowercase();
if !lower.starts_with("http://") && !lower.starts_with("https://") {
resolved = format!("http://{resolved}");
log::info!("[llm] endpoint missing scheme, auto-prepended http://");
}
// issue #609 F-01(SSRF):用户自定义 endpoint 是 attacker-controlled,直接拿来发
// 带 API Key 的请求等于把凭据指哪打哪。这里对 host/IP 段 + scheme 做配置时校验,
// 默认官方 endpoint 也过一遍(它本就合法)。注意这只防住"配置即字面内网地址",
+10 -3
View File
@@ -1252,9 +1252,16 @@ async fn send_with_transient_retry(
request: reqwest::RequestBuilder,
) -> Result<reqwest::Response, LLMError> {
const RETRY_DELAY_MS: u64 = 500;
let initial = request
.try_clone()
.expect("memory-backed body (json/form) must be clonable for retry");
let Some(initial) = request.try_clone() else {
// try_clone 失败(如 stream body 不可 clone)→ 不走重试,直接 send 一次。
// 用 expect 会 panic 杀死整个进程,这里兜底为单次发送。
log::warn!("[llm] request body not clonable, skipping retry");
return match request.send().await {
Ok(r) => Ok(r),
Err(e) if e.is_timeout() => Err(LLMError::Timeout),
Err(e) => Err(LLMError::Network(e.to_string())),
};
};
match initial.send().await {
Ok(r) => Ok(r),
Err(e) if e.is_connect() || e.is_request() => {