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()); + } +}