fix(llm): 为 Usage 字段添加反序列化容错,修复缺失子字段导致的响应解析失败

- Usage 结构体加 struct-level #[serde(default)]:子字段缺失默认 0/None

- OpenaiResponseBody.usage 与 OpenaiChatResponse.usage 加 field-level:整 key 缺失默认 Usage::default()

- 覆盖 Responses API 与 Chat Completions 两条 blocking 路径

- 补 9 个反序列化测试覆盖缺失/空/完整/roundtrip 边界

- 升级版本号至 0.3.6
This commit is contained in:
徐涛
2026-07-27 09:46:52 +08:00
parent b7d0a7335f
commit b4c4f4e718
4 changed files with 167 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agcore"
version = "0.3.5"
version = "0.3.6"
edition = "2024"
[features]
+46
View File
@@ -242,6 +242,7 @@ pub(crate) struct OpenaiChatResponse {
pub created: u64,
pub model: String,
pub choices: Vec<Choice>,
#[serde(default)]
pub usage: crate::llm::types::usage::Usage,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_fingerprint: Option<String>,
@@ -1968,4 +1969,49 @@ data: [DONE]\n\n";
"非法 header 名应被静默跳过"
);
}
// ===== OpenaiChatResponse Usage 容错(v0.3.6 引入)=====
/// C1: Chat Completions 响应中 usage 键完全缺失 —— 默认 Usage::default()。
#[test]
fn deserialize_chat_response_missing_usage_key() {
let body: OpenaiChatResponse = serde_json::from_value(json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1718000000,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "hi"},
"finish_reason": "stop"
}]
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 0);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
assert!(body.usage.completion_tokens_details.is_none());
assert!(body.usage.prompt_tokens_details.is_none());
}
/// C2: Chat Completions 响应中 usage 存在但缺子字段 —— 缺省字段默认 0。
#[test]
fn deserialize_chat_response_missing_usage_fields() {
let body: OpenaiChatResponse = serde_json::from_value(json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1718000000,
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "hi"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 8}
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 8);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
}
}
+89
View File
@@ -204,6 +204,7 @@ pub(crate) struct OpenaiResponseBody {
pub id: String,
pub model: String,
pub output: Vec<ResponseOutputItem>,
#[serde(default)]
pub usage: Usage,
pub status: String,
}
@@ -2509,4 +2510,92 @@ data: {\"type\":\"response.failed\",\"error\":{\"message\":\"server failed mid-s
let wire = serde_json::to_value(&tool).unwrap();
assert_eq!(wire, original);
}
// ===== Usage 字段容错(v0.3.6 引入)=====
/// U1: usage 子字段缺失 —— 缺失字段默认 0/None,结构体反序列化不报错。
#[test]
fn deserialize_missing_usage_fields() {
let body: OpenaiResponseBody = serde_json::from_value(json!({
"id": "r_1",
"model": "gpt-4o",
"output": [],
"usage": {"prompt_tokens": 5},
"status": "completed"
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 5);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
assert!(body.usage.completion_tokens_details.is_none());
assert!(body.usage.prompt_tokens_details.is_none());
}
/// U2: usage 键完全缺失 —— OpenaiResponseBody.usage 默认 Usage::default()。
#[test]
fn deserialize_missing_usage_key() {
let body: OpenaiResponseBody = serde_json::from_value(json!({
"id": "r_1",
"model": "gpt-4o",
"output": [],
"status": "completed"
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 0);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
assert!(body.usage.completion_tokens_details.is_none());
assert!(body.usage.prompt_tokens_details.is_none());
}
/// U3: usage 为空对象 —— 全字段走 Default,无 None panic。
#[test]
fn deserialize_empty_usage_object() {
let body: OpenaiResponseBody = serde_json::from_value(json!({
"id": "r_1",
"model": "gpt-4o",
"output": [],
"usage": {},
"status": "completed"
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 0);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
}
/// U4: 完整 usage(含 details)回归 —— 确保 #[serde(default)] 不破坏正常路径。
#[test]
fn deserialize_full_usage_with_details() {
let body: OpenaiResponseBody = serde_json::from_value(json!({
"id": "r_1",
"model": "gpt-4o",
"output": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"completion_tokens_details": {"reasoning_tokens": 5}
},
"status": "completed"
}))
.unwrap();
assert_eq!(body.usage.prompt_tokens, 10);
assert_eq!(body.usage.completion_tokens, 20);
assert_eq!(body.usage.total_tokens, 30);
let details = body.usage.completion_tokens_details.unwrap();
assert_eq!(details.reasoning_tokens, Some(5));
}
/// U5: OpenaiResponseBody 上下文中 usage key 完全缺失 —— 与 U2 同场景但明确命名。
#[test]
fn deserialize_response_body_missing_usage_key() {
let json_text = r#"{"id":"r_1","model":"gpt-4o","output":[],"status":"completed"}"#;
let body: OpenaiResponseBody = serde_json::from_str(json_text).unwrap();
assert_eq!(body.usage.prompt_tokens, 0);
assert_eq!(body.usage.completion_tokens, 0);
assert_eq!(body.usage.total_tokens, 0);
assert!(body.usage.completion_tokens_details.is_none());
assert!(body.usage.prompt_tokens_details.is_none());
}
}
+31
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
@@ -79,3 +80,33 @@ impl Usage {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserialize_usage_missing_prompt_tokens() {
let value = json!({
"completion_tokens": 5,
"total_tokens": 5
});
let usage: Usage = serde_json::from_value(value).unwrap();
assert_eq!(usage.prompt_tokens, 0);
assert_eq!(usage.completion_tokens, 5);
assert_eq!(usage.total_tokens, 5);
}
#[test]
fn serialize_deserialize_roundtrip() {
let original = Usage::from_input_output(10, 20);
let serialized = serde_json::to_value(original).unwrap();
let deserialized: Usage = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.prompt_tokens, original.prompt_tokens);
assert_eq!(deserialized.completion_tokens, original.completion_tokens);
assert_eq!(deserialized.total_tokens, original.total_tokens);
assert!(deserialized.completion_tokens_details.is_none());
assert!(deserialized.prompt_tokens_details.is_none());
}
}