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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agcore"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user