Skip to content

Commit 43c320f

Browse files
committed
fix(ai): 修复 DeepSeek 连通性 400 并适配 V4.1
根因(真机复现,错误体取自真实响应而非猜测): - DeepSeek 的 response_format=json_object 有前置条件——提示词须含 "json" 字样,否则 400 invalid_request_error;探活复用业务请求构造器(恒带 json_object)而探活提示词不含 json ⇒ 每次"测试连接"必 400 - post_completions 4xx 分支只读状态码、丢弃响应体 ⇒ 用户只见 "网络错误: 请求被拒绝 HTTP 400(不重试——4xx 非瞬态)" 改动: - 探活改最小 plain chat(无 response_format、512 token 上限、回显模型名) - 新增 ai_request_policy(纯函数 + 单测):json 前置条件兜底 / 错误体提取 / 思考模式策略,chat 与 vision 两条构造路径共用 - 4xx/5xx 透出上游 error.message(非 JSON 体回退原文、截断 300 字符); 流式 map_status 同口径 - 模型名现代化:预设改 deepseek-flash / deepseek-v4-pro;既有安装启动期 归一退役名(仅 api.deepseek.com 端点、幂等,OpenRouter 等同名条目不动); 前端预设同步 - 思考模式:结构化 JSON 任务默认 thinking=disabled(V4 家族默认 effort=high, 实测同一请求输出 token 16 → 172 且思考与正文抢 max_tokens),AI 对话保留 provider 默认;AI_THINKING=enabled 可整体切回;单价表按 V4.1 调价更新 - 文档:知识卡(根因/探针矩阵/真机证据)+ 索引登记;豁免表按实测纠偏; v0.22 补「工具链陷阱 3」(本机 cargo test --lib 在 DLL 加载期崩) 验证: - cargo test --test app_lib_tests:2357 passed / 0 failed / 6 ignored(exit 0) - cargo clippy --all-targets:exit 0,本次改动文件零新增警告 - npx tsc --noEmit 0 错;npx vitest run 103 文件 / 809 用例全绿 - 真机端到端(真实 DeepSeek API,5 组探针):探活 200、json 兜底后 200、 业务 JSON 任务 200 且 content 为合法 JSON、视觉 200(deepseek-flash 原生 多模态)、旧模型名(迁移前)仍 200
1 parent 5e7f300 commit 43c320f

21 files changed

Lines changed: 933 additions & 75 deletions

‎app/src-tauri/src/ai_chat_client.rs‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use crate::ai_chat::CancelFlag;
1111
use crate::ai_client::AiClient;
1212
use crate::ai_provider::{AiProviderConfig, ProviderKind, provider_scope};
13+
use crate::ai_request_policy::ThinkingPolicy;
1314
use crate::commands::AppState;
1415
use crate::db_ai_chat::ChatSession;
1516

@@ -25,6 +26,10 @@ pub fn validate_session(state: &AppState, session_id: i64) -> Result<ChatSession
2526
/// 会话客户端解析:session.provider_id 显式 > 默认 Provider;密钥口径
2627
/// 显式=per-scope(env 不覆盖显式选择),默认=resolve_default_provider_key
2728
/// (env > per-provider > legacy,与精修链同口径)。
29+
///
30+
/// @ai-context: 思考模式策略(2026-09-11):AI 对话是**推理型**交互(用户要
31+
/// 的是思考质量),故显式改回 ProviderDefault——V4 家族默认
32+
/// 开启思考;结构化 JSON 任务仍走默认关闭(成本/预算可预期)。
2833
pub fn resolve_chat_client(
2934
state: &AppState,
3035
session: &ChatSession,
@@ -48,7 +53,9 @@ pub fn resolve_chat_client(
4853
.map_err(|e| format!("AI 设置锁中毒: {}", e))?
4954
.clone();
5055
let stored_key = crate::commands_ai_providers::resolve_default_provider_key(state)?;
51-
Ok((AiClient::from_settings_with_store(&settings, stored_key, &store), None))
56+
let client = AiClient::from_settings_with_store(&settings, stored_key, &store)
57+
.with_thinking(ThinkingPolicy::ProviderDefault);
58+
Ok((client, None))
5259
}
5360

5461
/// 显式 Provider 客户端(Ollama 免密钥;其余 per-scope 凭据缺失 → 明确报错)。
@@ -58,13 +65,15 @@ pub fn build_provider_client(
5865
pid: &str,
5966
) -> Result<AiClient, String> {
6067
if provider.kind == ProviderKind::Ollama {
61-
return Ok(AiClient::from_provider(provider, None));
68+
return Ok(AiClient::from_provider(provider, None)
69+
.with_thinking(ThinkingPolicy::ProviderDefault));
6270
}
6371
let key = state
6472
.ai_credentials
6573
.load_key(&provider_scope(pid))?
6674
.ok_or_else(|| format!("Provider {} 未保存密钥(设置页保存后重试)", provider.name))?;
67-
Ok(AiClient::from_provider(provider, Some(key)))
75+
Ok(AiClient::from_provider(provider, Some(key))
76+
.with_thinking(ThinkingPolicy::ProviderDefault))
6877
}
6978

7079
/// 单活跃流注册(gate 之后、任何落库之前调用——防并发重复落库/扣费;

‎app/src-tauri/src/ai_chat_stream.rs‎

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -219,30 +219,50 @@ fn read_sse_lines(
219219
}
220220

221221
/// HTTP 状态 → AiClientError(与 post_completions 同归一口径——四下一致)。
222+
///
223+
/// @ai-context: 2026-09-11:流式路径同样透出上游 error.message(原先只报
224+
/// "HTTP 400",用户无从定位——与 post_completions 对齐)。
222225
fn map_status(e: ureq::Error) -> AiClientError {
223226
match e {
224-
ureq::Error::Status(401, _) => AiClientError::Auth(
225-
"API 密钥无效(HTTP 401)——请检查设置页密钥或环境变量".to_string(),
226-
),
227-
ureq::Error::Status(403, _) => AiClientError::Auth(
228-
"API 密钥无权限(HTTP 403)——账号未开通该模型或额度受限".to_string(),
229-
),
230-
ureq::Error::Status(402, _) => {
231-
AiClientError::Balance("账户余额不足(请充值或切换免费档模型)".to_string())
232-
}
233-
ureq::Error::Status(429, _) => {
234-
AiClientError::Quota("请求过频或配额耗尽(HTTP 429)".to_string())
235-
}
236-
ureq::Error::Status(code, _) if code >= 500 => {
237-
AiClientError::Server(format!("服务端错误 HTTP {}", code))
238-
}
239-
ureq::Error::Status(code, _) => {
240-
AiClientError::Network(format!("请求被拒绝 HTTP {}(不重试)", code))
227+
ureq::Error::Status(401, resp) => AiClientError::Auth(format!(
228+
"API 密钥无效(HTTP 401)——请检查设置页密钥或环境变量{}",
229+
error_suffix(resp)
230+
)),
231+
ureq::Error::Status(403, resp) => AiClientError::Auth(format!(
232+
"API 密钥无权限(HTTP 403)——账号未开通该模型或额度受限{}",
233+
error_suffix(resp)
234+
)),
235+
ureq::Error::Status(402, resp) => AiClientError::Balance(format!(
236+
"账户余额不足(请充值或切换免费档模型){}",
237+
error_suffix(resp)
238+
)),
239+
ureq::Error::Status(429, resp) => AiClientError::Quota(format!(
240+
"请求过频或配额耗尽(HTTP 429){}",
241+
error_suffix(resp)
242+
)),
243+
ureq::Error::Status(code, resp) if code >= 500 => {
244+
AiClientError::Server(format!("服务端错误 HTTP {}{}", code, error_suffix(resp)))
241245
}
246+
ureq::Error::Status(code, resp) => AiClientError::Network(format!(
247+
"请求被拒绝 HTTP {}(不重试){}",
248+
code,
249+
error_suffix(resp)
250+
)),
242251
e => AiClientError::Network(format!("传输错误: {}", e)),
243252
}
244253
}
245254

255+
/// 错误响应体 → 可读后缀(":上游原因";无可读信息 → 空串)。
256+
fn error_suffix(resp: ureq::Response) -> String {
257+
match resp.into_string() {
258+
Ok(body) => match crate::ai_request_policy::extract_api_error(&body) {
259+
Some(msg) => format!(":{}", msg),
260+
None => String::new(),
261+
},
262+
Err(_) => String::new(),
263+
}
264+
}
265+
246266
#[cfg(test)]
247267
#[path = "ai_chat_stream_tests.rs"]
248268
mod tests;

‎app/src-tauri/src/ai_chat_stream_tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ fn client_for(port: u16) -> AiClient {
4545
max_retries: 0,
4646
max_tokens: 128,
4747
is_local: false,
48+
thinking: crate::ai_request_policy::ThinkingPolicy::ProviderDefault,
4849
})
4950
}
5051

@@ -124,6 +125,7 @@ fn stream_empty_key_is_auth_error() {
124125
max_retries: 0,
125126
max_tokens: 8,
126127
is_local: false,
128+
thinking: crate::ai_request_policy::ThinkingPolicy::ProviderDefault,
127129
});
128130
let err = stream_chat(&client, &[], &CancelFlag::new(), |_| {}).unwrap_err();
129131
assert_eq!(err.kind(), "auth");

0 commit comments

Comments
 (0)