优化: AI模块(provider重试兼容+aichat交互+工具卡片可读化+diff高亮)

This commit is contained in:
2026-06-16 02:33:16 +08:00
parent d2cb38cdac
commit 10e4945e5a
26 changed files with 2337 additions and 332 deletions

View File

@@ -16,13 +16,16 @@ use crate::provider::{
CompletionRequest, CompletionResponse, LlmProvider, StreamChunk, StreamResult,
TokenUsage, ToolCall, ToolCallDelta,
};
use crate::retry::{
retry_with_backoff, AttemptOutcome, is_reqwest_error_retryable, is_status_retryable,
};
// ============================================================
// OpenAI API 请求/响应结构体
// ============================================================
/// OpenAI 兼容请求体
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
@@ -41,7 +44,7 @@ struct OpenAiRequest {
}
/// OpenAI 消息格式
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenAiMessage {
role: String,
content: String,
@@ -398,60 +401,75 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 同步调用");
// 同步路径整体超时(FR-R4)client 仅配 connect_timeout无总 timeout防误砍流式长生成
// complete 为一次性请求,挂死会让整轮对话静默卡住,此处用 RequestBuilder 的单请求 timeout 兜底
// 超时返回 is_timeout 错误(不静默挂),且不影响 stream() 流式路径
let resp = self
.client
.post(self.chat_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.json(&openai_req)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
error!(model = %openai_req.model, "OpenAI 同步调用超时(60s)");
anyhow::anyhow!("LLM 同步调用超时(60s),请检查网络或模型可用性: {}", e)
} else {
anyhow::anyhow!(e)
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s
// 由 retry_with_backoff 内部 30s 总预算主动止损
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
let url = self.chat_url();
let api_key = self.api_key.clone();
let openai_req = openai_req.clone();
async move {
// send
let resp = client
.post(url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.json(&openai_req)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
return AttemptOutcome::Fatal(format!("请求失败(不可重试): {}", e));
}
};
// 状态码判定
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let msg = format!("LLM API 错误 {}: {}", status, body);
if is_status_retryable(status) {
warn!(%status, "OpenAI 同步调用可重试状态码");
return AttemptOutcome::Retryable(msg);
}
error!(%status, %body, "LLM API 调用失败(不可重试)");
return AttemptOutcome::Fatal(msg);
}
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!(%status, %body, "LLM API 调用失败");
anyhow::bail!("LLM API 错误 {}: {}", status, body);
}
let body: OpenAiResponse = resp.json().await?;
let choice = body
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("LLM 响应无 choices"))?;
let text = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
let usage = body.usage.map(|u| TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
}).unwrap_or(TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
});
Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
// body 解析: 解析错属 Fatal(响应已成功送达,重试也会因同样格式失败)
let body: OpenAiResponse = match resp.json().await {
Ok(b) => b,
Err(e) => return AttemptOutcome::Fatal(format!("响应解析失败: {}", e)),
};
let choice = match body.choices.into_iter().next() {
Some(c) => c,
None => return AttemptOutcome::Fatal("LLM 响应无 choices".to_string()),
};
let text = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(Self::parse_tool_calls);
let usage = body.usage.map(|u| TokenUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
}).unwrap_or(TokenUsage {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
});
AttemptOutcome::Ok(CompletionResponse {
text,
model: body.model,
usage,
tool_calls,
})
}
})
.await
}
async fn stream(&self, request: CompletionRequest) -> anyhow::Result<StreamResult> {