From 25238fc357008f1cce7940e3eeb0535e30b14ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Mon, 27 Jul 2026 09:46:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E4=B8=BA=20Usage=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=B7=BB=E5=8A=A0=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E5=AE=B9=E9=94=99=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E5=AD=90=E5=AD=97=E6=AE=B5=E5=AF=BC=E8=87=B4=E7=9A=84=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.toml | 2 +- src/llm/provider/openai.rs | 46 +++++++++++++++ src/llm/provider/openai_response.rs | 89 +++++++++++++++++++++++++++++ src/llm/types/usage.rs | 31 ++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index eb17994..9b3a503 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agcore" -version = "0.3.5" +version = "0.3.6" edition = "2024" [features] diff --git a/src/llm/provider/openai.rs b/src/llm/provider/openai.rs index 42e5930..0f42b3e 100644 --- a/src/llm/provider/openai.rs +++ b/src/llm/provider/openai.rs @@ -242,6 +242,7 @@ pub(crate) struct OpenaiChatResponse { pub created: u64, pub model: String, pub choices: Vec, + #[serde(default)] pub usage: crate::llm::types::usage::Usage, #[serde(skip_serializing_if = "Option::is_none")] pub system_fingerprint: Option, @@ -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); + } } diff --git a/src/llm/provider/openai_response.rs b/src/llm/provider/openai_response.rs index 4650acc..a4d019a 100644 --- a/src/llm/provider/openai_response.rs +++ b/src/llm/provider/openai_response.rs @@ -204,6 +204,7 @@ pub(crate) struct OpenaiResponseBody { pub id: String, pub model: String, pub output: Vec, + #[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()); + } } diff --git a/src/llm/types/usage.rs b/src/llm/types/usage.rs index fbeb44c..84689e4 100644 --- a/src/llm/types/usage.rs +++ b/src/llm/types/usage.rs @@ -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()); + } +}