- 同步阻塞操作(附件处理、历史加载、scheduler/memory_search 的 SQLite 调用) 移入 spawn_blocking,避免占用 async worker - LLM Provider reqwest::Client 按超时配置缓存复用,减少 TLS/连接开销 - agent loop:图片过滤加廉价预判避免全量深拷贝;请求克隆改借用;工具定义 Arc 化 - 定向 COUNT/LIMIT 1 查询替代全量加载计数(wait_coordinator、task session 重建) - 前端:面板/侧栏/聊天组件 memo 化;merged_tool 对象按值复用缓存; 流式 delta rAF 节流批量 flush;useMemo 缓存分组排序结果
688 lines
22 KiB
Rust
688 lines
22 KiB
Rust
use async_trait::async_trait;
|
||
use reqwest::Client;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use std::sync::OnceLock;
|
||
|
||
use super::traits::Usage;
|
||
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Tool, ToolCall};
|
||
use crate::domain::messages::ContentBlock;
|
||
use crate::utils::format_error_chain;
|
||
|
||
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["supported_content_types"];
|
||
|
||
fn serialize_content_blocks<S>(
|
||
blocks: &[serde_json::Value],
|
||
serializer: S,
|
||
) -> Result<S::Ok, S::Error>
|
||
where
|
||
S: serde::Serializer,
|
||
{
|
||
serializer.serialize_str(&serde_json::to_string(blocks).unwrap_or_else(|_| "[]".to_string()))
|
||
}
|
||
|
||
fn convert_content_blocks(
|
||
supports_images: bool,
|
||
provider_name: &str,
|
||
model_id: &str,
|
||
blocks: &[ContentBlock],
|
||
message_idx: usize,
|
||
) -> Vec<serde_json::Value> {
|
||
// 检查是否有图片且模型不支持
|
||
if !supports_images {
|
||
let has_images = blocks
|
||
.iter()
|
||
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
||
|
||
if has_images {
|
||
let image_count = blocks
|
||
.iter()
|
||
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
|
||
.count();
|
||
|
||
tracing::warn!(
|
||
provider = %provider_name,
|
||
model = %model_id,
|
||
filtered_images = image_count,
|
||
message_idx,
|
||
"模型不支持图片;将图片转换为通知文本"
|
||
);
|
||
|
||
// 复用通知格式,将图片转换为文本通知
|
||
let mut converted_blocks: Vec<serde_json::Value> = Vec::new();
|
||
let mut notices: Vec<String> = Vec::new();
|
||
let mut image_idx = 0;
|
||
|
||
for block in blocks.iter() {
|
||
match block {
|
||
ContentBlock::Text { text } => {
|
||
converted_blocks.push(serde_json::json!({ "type": "text", "text": text }));
|
||
}
|
||
ContentBlock::ImageUrl { .. } => {
|
||
image_idx += 1;
|
||
notices.push(format!(
|
||
"- 第 {} 张图片:当前模型不支持图片输入,该图片未能成功入模,请直接告知用户。",
|
||
image_idx
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 添加通知文本块
|
||
if !notices.is_empty() {
|
||
let notice_text =
|
||
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
|
||
}
|
||
|
||
return converted_blocks;
|
||
}
|
||
}
|
||
|
||
// 原有逻辑 - 模型支持图片,正常转换
|
||
blocks
|
||
.iter()
|
||
.map(|b| match b {
|
||
ContentBlock::Text { text } => {
|
||
serde_json::json!({ "type": "text", "text": text })
|
||
}
|
||
ContentBlock::ImageUrl { image_url } => convert_image_url_to_anthropic(&image_url.url),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn convert_image_url_to_anthropic(url: &str) -> serde_json::Value {
|
||
// data:image/png;base64,... -> Anthropic image block
|
||
static RE: OnceLock<regex::Regex> = OnceLock::new();
|
||
let re =
|
||
RE.get_or_init(|| regex::Regex::new(r"data:(image/\w+);base64,(.+)").expect("valid regex"));
|
||
if let Some(caps) = re.captures(url) {
|
||
let media_type = caps.get(1).map(|m| m.as_str()).unwrap_or("image/png");
|
||
let data = caps.get(2).map(|d| d.as_str()).unwrap_or("");
|
||
return serde_json::json!({
|
||
"type": "image",
|
||
"source": {
|
||
"type": "base64",
|
||
"media_type": media_type,
|
||
"data": data
|
||
}
|
||
});
|
||
}
|
||
// Regular URL -> Anthropic image block with url source
|
||
serde_json::json!({
|
||
"type": "image",
|
||
"source": {
|
||
"type": "url",
|
||
"url": url
|
||
}
|
||
})
|
||
}
|
||
|
||
pub struct AnthropicProvider {
|
||
client: Client,
|
||
name: String,
|
||
api_key: String,
|
||
base_url: String,
|
||
extra_headers: HashMap<String, String>,
|
||
llm_timeout_secs: u64,
|
||
model_id: String,
|
||
temperature: Option<f32>,
|
||
max_tokens: Option<u32>,
|
||
model_extra: HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
impl AnthropicProvider {
|
||
pub fn new(
|
||
name: String,
|
||
api_key: String,
|
||
base_url: String,
|
||
extra_headers: HashMap<String, String>,
|
||
llm_timeout_secs: u64,
|
||
model_id: String,
|
||
temperature: Option<f32>,
|
||
max_tokens: Option<u32>,
|
||
model_extra: HashMap<String, serde_json::Value>,
|
||
) -> Self {
|
||
// 复用按超时配置的共享 client(TLS 上下文 + 连接池),
|
||
// 避免每条消息重建 Provider 时重复构造。
|
||
let client = crate::providers::shared_llm_http_client(llm_timeout_secs);
|
||
|
||
// 兼容带末尾斜杠的 base_url,避免 format!("{}/v1/messages", base_url) 产生双斜杠
|
||
let base_url = base_url.trim_end_matches('/').to_string();
|
||
|
||
Self {
|
||
client,
|
||
name,
|
||
api_key,
|
||
base_url,
|
||
extra_headers,
|
||
llm_timeout_secs,
|
||
model_id,
|
||
temperature,
|
||
max_tokens,
|
||
model_extra,
|
||
}
|
||
}
|
||
|
||
/// 检查模型是否支持指定内容类型
|
||
/// 默认支持所有类型(text, image)
|
||
fn supports_content_type(&self, content_type: &str) -> bool {
|
||
self.model_extra
|
||
.get("supported_content_types")
|
||
.and_then(|value| value.as_array())
|
||
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// 检查模型是否支持图片
|
||
fn supports_images(&self) -> bool {
|
||
self.supports_content_type("image")
|
||
}
|
||
|
||
/// 过滤掉内部字段,只返回需要发送到 API 的 extra 字段
|
||
fn request_model_extra(&self) -> HashMap<String, serde_json::Value> {
|
||
self.model_extra
|
||
.iter()
|
||
.filter(|(key, _)| !INTERNAL_MODEL_EXTRA_KEYS.contains(&key.as_str()))
|
||
.map(|(k, v)| (k.clone(), v.clone()))
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct AnthropicRequest {
|
||
model: String,
|
||
messages: Vec<AnthropicMessage>,
|
||
max_tokens: u32,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
temperature: Option<f32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
tools: Option<Vec<AnthropicTool>>,
|
||
#[serde(flatten)]
|
||
extra: HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct AnthropicMessage {
|
||
role: String,
|
||
#[serde(serialize_with = "serialize_content_blocks")]
|
||
content: Vec<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct AnthropicTool {
|
||
name: String,
|
||
description: String,
|
||
input_schema: serde_json::Value,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct AnthropicResponse {
|
||
id: String,
|
||
model: String,
|
||
content: Vec<AnthropicContent>,
|
||
usage: AnthropicUsage,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
enum AnthropicContent {
|
||
Text {
|
||
text: String,
|
||
},
|
||
#[allow(dead_code)]
|
||
Thinking {
|
||
thinking: String,
|
||
},
|
||
#[serde(rename = "tool_use")]
|
||
ToolUse {
|
||
id: String,
|
||
name: String,
|
||
input: serde_json::Value,
|
||
},
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct AnthropicUsage {
|
||
input_tokens: u32,
|
||
output_tokens: u32,
|
||
/// 从服务端缓存读取的输入 tokens 数(命中缓存部分)
|
||
#[serde(default)]
|
||
cache_read_input_tokens: Option<u32>,
|
||
}
|
||
|
||
#[async_trait]
|
||
impl LLMProvider for AnthropicProvider {
|
||
#[tracing::instrument(skip(self, request), fields(provider = %self.name, model = %self.model_id))]
|
||
async fn chat(
|
||
&self,
|
||
request: &ChatCompletionRequest,
|
||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||
let url = format!("{}/v1/messages", self.base_url);
|
||
let max_tokens = request.max_tokens.or(self.max_tokens).unwrap_or(8192);
|
||
|
||
tracing::info!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
message_count = request.messages.len(),
|
||
has_tools = request.tools.is_some(),
|
||
"Anthropic: sending chat completion request"
|
||
);
|
||
|
||
let tools = request.tools.as_ref().map(|tools| {
|
||
tools
|
||
.iter()
|
||
.map(|t: &Tool| AnthropicTool {
|
||
name: t.function.name.clone(),
|
||
description: t.function.description.clone(),
|
||
input_schema: t.function.parameters.clone(),
|
||
})
|
||
.collect()
|
||
});
|
||
|
||
let body = AnthropicRequest {
|
||
model: self.model_id.clone(),
|
||
messages: request
|
||
.messages
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, m)| AnthropicMessage {
|
||
role: m.role.clone(),
|
||
content: convert_content_blocks(
|
||
self.supports_images(),
|
||
&self.name,
|
||
&self.model_id,
|
||
&m.content,
|
||
i,
|
||
),
|
||
})
|
||
.collect(),
|
||
max_tokens,
|
||
temperature: request.temperature.or(self.temperature),
|
||
tools,
|
||
extra: self.request_model_extra(),
|
||
};
|
||
|
||
let mut req_builder = self
|
||
.client
|
||
.post(&url)
|
||
.header("x-api-key", &self.api_key)
|
||
.header("anthropic-version", "2023-06-01")
|
||
.header("Content-Type", "application/json");
|
||
|
||
for (key, value) in &self.extra_headers {
|
||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||
}
|
||
|
||
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
timeout_secs = self.llm_timeout_secs,
|
||
error = %format_error_chain(e),
|
||
"Anthropic: HTTP request failed"
|
||
);
|
||
})?;
|
||
let status = resp.status();
|
||
let text = resp.text().await?;
|
||
|
||
if !status.is_success() {
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
status = %status,
|
||
response_len = text.len(),
|
||
response_body = %text,
|
||
"Anthropic API request failed"
|
||
);
|
||
return Err(format!("API error {}: {}", status, text).into());
|
||
}
|
||
|
||
tracing::debug!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
status = %status,
|
||
response_len = text.len(),
|
||
"Anthropic response received"
|
||
);
|
||
|
||
let anthropic_resp: AnthropicResponse = serde_json::from_str(&text).map_err(|e| {
|
||
tracing::error!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
url = %url,
|
||
error = %format_error_chain(&e),
|
||
response_len = text.len(),
|
||
response_body = %text,
|
||
"Failed to decode Anthropic response"
|
||
);
|
||
format!("decode error: {} | body: {}", e, &text)
|
||
})?;
|
||
|
||
let mut content = String::new();
|
||
let mut tool_calls = Vec::new();
|
||
|
||
for c in &anthropic_resp.content {
|
||
match c {
|
||
AnthropicContent::Text { text } => {
|
||
if !text.is_empty() {
|
||
if !content.is_empty() {
|
||
content.push('\n');
|
||
}
|
||
content.push_str(text);
|
||
}
|
||
}
|
||
AnthropicContent::Thinking { .. } => {}
|
||
AnthropicContent::ToolUse { id, name, input } => {
|
||
tool_calls.push(ToolCall {
|
||
id: id.clone(),
|
||
name: name.clone(),
|
||
arguments: input.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
let usage = Usage {
|
||
prompt_tokens: anthropic_resp.usage.input_tokens,
|
||
completion_tokens: anthropic_resp.usage.output_tokens,
|
||
total_tokens: anthropic_resp.usage.input_tokens + anthropic_resp.usage.output_tokens,
|
||
cached_tokens: anthropic_resp.usage.cache_read_input_tokens.unwrap_or(0),
|
||
};
|
||
|
||
tracing::info!(
|
||
provider = %self.name,
|
||
model = %self.model_id,
|
||
prompt_tokens = usage.prompt_tokens,
|
||
completion_tokens = usage.completion_tokens,
|
||
total_tokens = usage.total_tokens,
|
||
has_tool_calls = !tool_calls.is_empty(),
|
||
"Anthropic: chat completion completed"
|
||
);
|
||
|
||
Ok(ChatCompletionResponse {
|
||
id: anthropic_resp.id,
|
||
model: anthropic_resp.model,
|
||
content,
|
||
reasoning_content: None,
|
||
tool_calls,
|
||
usage,
|
||
})
|
||
}
|
||
|
||
fn ptype(&self) -> &str {
|
||
"anthropic"
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
&self.name
|
||
}
|
||
|
||
fn model_id(&self) -> &str {
|
||
&self.model_id
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::domain::messages::ContentBlock;
|
||
use std::collections::HashMap;
|
||
|
||
/// 构造一个最小 Provider 用于测试配置驱动的方法
|
||
fn make_provider(model_extra: HashMap<String, serde_json::Value>) -> AnthropicProvider {
|
||
AnthropicProvider::new(
|
||
"test".to_string(),
|
||
"key".to_string(),
|
||
"https://api.test".to_string(),
|
||
HashMap::new(),
|
||
30,
|
||
"claude-test".to_string(),
|
||
None,
|
||
None,
|
||
model_extra,
|
||
)
|
||
}
|
||
|
||
// ---- convert_image_url_to_anthropic ----
|
||
|
||
#[test]
|
||
fn test_convert_data_url_extracts_media_type_and_base64() {
|
||
let url = "data:image/png;base64,iVBORw0KGgo=";
|
||
let v = convert_image_url_to_anthropic(url);
|
||
assert_eq!(v["type"], "image");
|
||
assert_eq!(v["source"]["type"], "base64");
|
||
assert_eq!(v["source"]["media_type"], "image/png");
|
||
assert_eq!(v["source"]["data"], "iVBORw0KGgo=");
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_data_url_jpeg() {
|
||
let url = "data:image/jpeg;base64,/9j/4AAQ";
|
||
let v = convert_image_url_to_anthropic(url);
|
||
assert_eq!(v["source"]["media_type"], "image/jpeg");
|
||
assert_eq!(v["source"]["data"], "/9j/4AAQ");
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_regular_url_uses_url_source() {
|
||
let url = "https://example.com/img.png";
|
||
let v = convert_image_url_to_anthropic(url);
|
||
assert_eq!(v["type"], "image");
|
||
assert_eq!(v["source"]["type"], "url");
|
||
assert_eq!(v["source"]["url"], url);
|
||
}
|
||
|
||
// ---- convert_content_blocks: 图片不支持时的过滤 ----
|
||
|
||
#[test]
|
||
fn test_convert_blocks_filters_images_when_unsupported() {
|
||
let blocks = vec![
|
||
ContentBlock::text("hello"),
|
||
ContentBlock::image_url("data:image/png;base64,abc"),
|
||
ContentBlock::image_url("data:image/png;base64,def"),
|
||
];
|
||
let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0);
|
||
// 文本块保留,图片块被替换为通知
|
||
assert_eq!(result.len(), 2);
|
||
assert_eq!(result[0]["type"], "text");
|
||
assert_eq!(result[0]["text"], "hello");
|
||
// 第二个是合并的图片通知
|
||
assert_eq!(result[1]["type"], "text");
|
||
let notice = result[1]["text"].as_str().unwrap();
|
||
assert!(notice.contains("第 1 张图片"));
|
||
assert!(notice.contains("第 2 张图片"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_blocks_keeps_images_when_supported() {
|
||
let blocks = vec![
|
||
ContentBlock::text("hi"),
|
||
ContentBlock::image_url("data:image/png;base64,abc"),
|
||
];
|
||
let result = convert_content_blocks(true, "test", "claude-test", &blocks, 0);
|
||
assert_eq!(result.len(), 2);
|
||
assert_eq!(result[0]["type"], "text");
|
||
assert_eq!(result[1]["type"], "image");
|
||
assert_eq!(result[1]["source"]["data"], "abc");
|
||
}
|
||
|
||
#[test]
|
||
fn test_convert_blocks_text_only_passthrough() {
|
||
let blocks = vec![ContentBlock::text("just text")];
|
||
let result = convert_content_blocks(false, "test", "claude-test", &blocks, 0);
|
||
assert_eq!(result.len(), 1);
|
||
assert_eq!(result[0]["type"], "text");
|
||
}
|
||
|
||
// ---- request_model_extra: 内部字段过滤 ----
|
||
|
||
#[test]
|
||
fn test_request_model_extra_filters_internal_keys() {
|
||
let mut extra = HashMap::new();
|
||
extra.insert(
|
||
"supported_content_types".to_string(),
|
||
serde_json::json!(["text"]),
|
||
);
|
||
extra.insert("top_p".to_string(), serde_json::json!(0.9));
|
||
let provider = make_provider(extra);
|
||
let filtered = provider.request_model_extra();
|
||
// 内部字段被过滤
|
||
assert!(!filtered.contains_key("supported_content_types"));
|
||
// 业务字段保留
|
||
assert_eq!(filtered.get("top_p").and_then(|v| v.as_f64()), Some(0.9));
|
||
}
|
||
|
||
#[test]
|
||
fn test_request_model_extra_empty_when_only_internal() {
|
||
let mut extra = HashMap::new();
|
||
extra.insert(
|
||
"supported_content_types".to_string(),
|
||
serde_json::json!(["text", "image"]),
|
||
);
|
||
let provider = make_provider(extra);
|
||
assert!(provider.request_model_extra().is_empty());
|
||
}
|
||
|
||
// ---- supports_images: 配置驱动 ----
|
||
|
||
#[test]
|
||
fn test_supports_images_default_true() {
|
||
let provider = make_provider(HashMap::new());
|
||
assert!(provider.supports_images());
|
||
}
|
||
|
||
#[test]
|
||
fn test_supports_images_disabled_via_config() {
|
||
let mut extra = HashMap::new();
|
||
extra.insert(
|
||
"supported_content_types".to_string(),
|
||
serde_json::json!(["text"]),
|
||
);
|
||
let provider = make_provider(extra);
|
||
assert!(!provider.supports_images());
|
||
}
|
||
|
||
// ---- AnthropicResponse 反序列化 ----
|
||
|
||
#[test]
|
||
fn test_deserialize_response_with_text_and_tool_use() {
|
||
let json = r#"{
|
||
"id": "msg_001",
|
||
"model": "claude-3-sonnet",
|
||
"content": [
|
||
{"type": "text", "text": "I'll use a tool"},
|
||
{"type": "tool_use", "id": "call_1", "name": "bash", "input": {"cmd": "ls"}}
|
||
],
|
||
"usage": {"input_tokens": 10, "output_tokens": 20}
|
||
}"#;
|
||
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
|
||
assert_eq!(resp.id, "msg_001");
|
||
assert_eq!(resp.content.len(), 2);
|
||
match &resp.content[0] {
|
||
AnthropicContent::Text { text } => assert_eq!(text, "I'll use a tool"),
|
||
_ => panic!("expected Text"),
|
||
}
|
||
match &resp.content[1] {
|
||
AnthropicContent::ToolUse { id, name, input } => {
|
||
assert_eq!(id, "call_1");
|
||
assert_eq!(name, "bash");
|
||
assert_eq!(input["cmd"], "ls");
|
||
}
|
||
_ => panic!("expected ToolUse"),
|
||
}
|
||
assert_eq!(resp.usage.input_tokens, 10);
|
||
assert_eq!(resp.usage.output_tokens, 20);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_response_thinking_variant() {
|
||
let json = r#"{
|
||
"id": "msg_002",
|
||
"model": "claude-3",
|
||
"content": [
|
||
{"type": "thinking", "thinking": "internal reasoning"}
|
||
],
|
||
"usage": {"input_tokens": 5, "output_tokens": 5}
|
||
}"#;
|
||
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
|
||
assert_eq!(resp.content.len(), 1);
|
||
match &resp.content[0] {
|
||
AnthropicContent::Thinking { thinking } => {
|
||
assert_eq!(thinking, "internal reasoning");
|
||
}
|
||
_ => panic!("expected Thinking"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_response_empty_content() {
|
||
let json = r#"{
|
||
"id": "msg_003",
|
||
"model": "claude-3",
|
||
"content": [],
|
||
"usage": {"input_tokens": 1, "output_tokens": 1}
|
||
}"#;
|
||
let resp: AnthropicResponse = serde_json::from_str(json).unwrap();
|
||
assert!(resp.content.is_empty());
|
||
}
|
||
|
||
// ---- AnthropicRequest 序列化:未配置的可选字段不出现在请求体中 ----
|
||
|
||
fn make_request(temperature: Option<f32>) -> AnthropicRequest {
|
||
AnthropicRequest {
|
||
model: "glm-5".to_string(),
|
||
messages: vec![AnthropicMessage {
|
||
role: "user".to_string(),
|
||
content: vec![serde_json::json!({"type": "text", "text": "hi"})],
|
||
}],
|
||
max_tokens: 1024,
|
||
temperature,
|
||
tools: None,
|
||
extra: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_request_omits_temperature_when_none() {
|
||
let json = serde_json::to_value(make_request(None)).unwrap();
|
||
let obj = json.as_object().unwrap();
|
||
assert!(!obj.contains_key("temperature"));
|
||
assert_eq!(obj.get("max_tokens").and_then(|v| v.as_u64()), Some(1024));
|
||
}
|
||
|
||
#[test]
|
||
fn test_request_includes_temperature_when_set() {
|
||
let json = serde_json::to_value(make_request(Some(0.5))).unwrap();
|
||
assert_eq!(json["temperature"], serde_json::json!(0.5));
|
||
}
|
||
|
||
// ---- format_error_chain ----
|
||
|
||
#[test]
|
||
fn test_format_error_chain_single() {
|
||
let err = std::io::Error::other("single error");
|
||
let chain = format_error_chain(&err);
|
||
assert_eq!(chain, "single error");
|
||
}
|
||
|
||
/// 用 thiserror 构造真正的嵌套 source 链,验证 "caused by" 拼接
|
||
#[derive(Debug, thiserror::Error)]
|
||
enum OuterError {
|
||
#[error("outer wrapper")]
|
||
Wrapped(#[source] std::io::Error),
|
||
}
|
||
|
||
#[test]
|
||
fn test_format_error_chain_nested() {
|
||
let inner = std::io::Error::other("root cause");
|
||
let outer = OuterError::Wrapped(inner);
|
||
let chain = format_error_chain(&outer);
|
||
assert!(chain.contains("outer wrapper"));
|
||
assert!(chain.contains("caused by"));
|
||
assert!(chain.contains("root cause"));
|
||
}
|
||
}
|