| |
| use super::models::*; |
| use serde_json::Value; |
|
|
| pub fn transform_openai_response(gemini_response: &Value, session_id: Option<&str>, message_count: usize) -> OpenAIResponse { |
| |
| let raw = gemini_response.get("response").unwrap_or(gemini_response); |
|
|
| let mut choices = Vec::new(); |
|
|
| |
| if let Some(candidates) = raw.get("candidates").and_then(|c| c.as_array()) { |
| for (idx, candidate) in candidates.iter().enumerate() { |
| let mut content_out = String::new(); |
| let mut thought_out = String::new(); |
| let mut tool_calls = Vec::new(); |
|
|
| |
| if let Some(parts) = candidate |
| .get("content") |
| .and_then(|c| c.get("parts")) |
| .and_then(|p| p.as_array()) |
| { |
| for part in parts { |
| |
| if let Some(sig) = part |
| .get("thoughtSignature") |
| .or(part.get("thought_signature")) |
| .and_then(|s| s.as_str()) |
| { |
| if let Some(sid) = session_id { |
| super::streaming::store_thought_signature(sig, sid, message_count); |
| } |
| } |
|
|
| |
| let is_thought_part = part |
| .get("thought") |
| .and_then(|v| v.as_bool()) |
| .unwrap_or(false); |
|
|
| |
| if let Some(text) = part.get("text").and_then(|t| t.as_str()) { |
| if is_thought_part { |
| |
| thought_out.push_str(text); |
| } else { |
| |
| content_out.push_str(text); |
| } |
| } |
|
|
| |
| if let Some(fc) = part.get("functionCall") { |
| let name = fc.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); |
| let args = fc |
| .get("args") |
| .map(|v| v.to_string()) |
| .unwrap_or_else(|| "{}".to_string()); |
| let id = fc |
| .get("id") |
| .and_then(|v| v.as_str()) |
| .map(|s| s.to_string()) |
| .unwrap_or_else(|| format!("{}-{}", name, uuid::Uuid::new_v4())); |
|
|
| tool_calls.push(ToolCall { |
| id, |
| r#type: "function".to_string(), |
| function: ToolFunction { |
| name: name.to_string(), |
| arguments: args, |
| }, |
| }); |
| } |
|
|
| |
| if let Some(img) = part.get("inlineData") { |
| let mime_type = img |
| .get("mimeType") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("image/png"); |
| let data = img.get("data").and_then(|v| v.as_str()).unwrap_or(""); |
| if !data.is_empty() { |
| content_out |
| .push_str(&format!("", mime_type, data)); |
| } |
| } |
| } |
| } |
|
|
| |
| if let Some(grounding) = candidate.get("groundingMetadata") { |
| let mut grounding_text = String::new(); |
|
|
| |
| if let Some(queries) = grounding.get("webSearchQueries").and_then(|q| q.as_array()) |
| { |
| let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect(); |
| if !query_list.is_empty() { |
| grounding_text.push_str("\n\n---\n**🔍 已为您搜索:** "); |
| grounding_text.push_str(&query_list.join(", ")); |
| } |
| } |
|
|
| |
| if let Some(chunks) = grounding.get("groundingChunks").and_then(|c| c.as_array()) { |
| let mut links = Vec::new(); |
| for (i, chunk) in chunks.iter().enumerate() { |
| if let Some(web) = chunk.get("web") { |
| let title = web |
| .get("title") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("网页来源"); |
| let uri = web.get("uri").and_then(|v| v.as_str()).unwrap_or("#"); |
| links.push(format!("[{}] [{}]({})", i + 1, title, uri)); |
| } |
| } |
|
|
| if !links.is_empty() { |
| grounding_text.push_str("\n\n**🌐 来源引文:**\n"); |
| grounding_text.push_str(&links.join("\n")); |
| } |
| } |
|
|
| if !grounding_text.is_empty() { |
| content_out.push_str(&grounding_text); |
| } |
| } |
|
|
| |
| let finish_reason = candidate |
| .get("finishReason") |
| .and_then(|f| f.as_str()) |
| .map(|f| match f { |
| "STOP" => "stop", |
| "MAX_TOKENS" => "length", |
| "SAFETY" => "content_filter", |
| "RECITATION" => "content_filter", |
| _ => "stop", |
| }) |
| .unwrap_or("stop"); |
|
|
| choices.push(Choice { |
| index: idx as u32, |
| message: OpenAIMessage { |
| role: "assistant".to_string(), |
| content: if content_out.is_empty() { |
| None |
| } else { |
| Some(OpenAIContent::String(content_out)) |
| }, |
| reasoning_content: if thought_out.is_empty() { |
| None |
| } else { |
| Some(thought_out) |
| }, |
| tool_calls: if tool_calls.is_empty() { |
| None |
| } else { |
| Some(tool_calls) |
| }, |
| tool_call_id: None, |
| name: None, |
| }, |
| finish_reason: Some(finish_reason.to_string()), |
| }); |
| } |
| } |
|
|
| |
| let usage = raw.get("usageMetadata").and_then(|u| { |
| let prompt_tokens = u |
| .get("promptTokenCount") |
| .and_then(|v| v.as_u64()) |
| .unwrap_or(0) as u32; |
| let completion_tokens = u |
| .get("candidatesTokenCount") |
| .and_then(|v| v.as_u64()) |
| .unwrap_or(0) as u32; |
| let total_tokens = u |
| .get("totalTokenCount") |
| .and_then(|v| v.as_u64()) |
| .unwrap_or(0) as u32; |
| let cached_tokens = u |
| .get("cachedContentTokenCount") |
| .and_then(|v| v.as_u64()) |
| .map(|v| v as u32); |
|
|
| Some(super::models::OpenAIUsage { |
| prompt_tokens, |
| completion_tokens, |
| total_tokens, |
| prompt_tokens_details: cached_tokens.map(|ct| super::models::PromptTokensDetails { |
| cached_tokens: Some(ct), |
| }), |
| completion_tokens_details: None, |
| }) |
| }); |
|
|
| OpenAIResponse { |
| id: raw |
| .get("responseId") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("resp_unknown") |
| .to_string(), |
| object: "chat.completion".to_string(), |
| created: chrono::Utc::now().timestamp() as u64, |
| model: raw |
| .get("modelVersion") |
| .and_then(|v| v.as_str()) |
| .unwrap_or("unknown") |
| .to_string(), |
| choices, |
| usage, |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use serde_json::json; |
|
|
| #[test] |
| fn test_transform_openai_response() { |
| let gemini_resp = json!({ |
| "candidates": [{ |
| "content": { |
| "parts": [{"text": "Hello!"}] |
| }, |
| "finishReason": "STOP" |
| }], |
| "modelVersion": "gemini-2.5-flash", |
| "responseId": "resp_123" |
| }); |
|
|
| let result = transform_openai_response(&gemini_resp, Some("session-123"), 1); |
| assert_eq!(result.object, "chat.completion"); |
| let content = match result.choices[0].message.content.as_ref().unwrap() { |
| OpenAIContent::String(s) => s, |
| _ => panic!("Expected string content"), |
| }; |
| assert_eq!(content, "Hello!"); |
| assert_eq!(result.choices[0].finish_reason, Some("stop".to_string())); |
| } |
|
|
| #[test] |
| fn test_usage_metadata_mapping() { |
| let gemini_resp = json!({ |
| "candidates": [{ |
| "content": {"parts": [{"text": "Hello!"}]}, |
| "finishReason": "STOP" |
| }], |
| "usageMetadata": { |
| "promptTokenCount": 100, |
| "candidatesTokenCount": 50, |
| "totalTokenCount": 150, |
| "cachedContentTokenCount": 25 |
| }, |
| "modelVersion": "gemini-2.5-flash", |
| "responseId": "resp_123" |
| }); |
|
|
| let result = transform_openai_response(&gemini_resp, Some("session-123"), 1); |
|
|
| assert!(result.usage.is_some()); |
| let usage = result.usage.unwrap(); |
| assert_eq!(usage.prompt_tokens, 100); |
| assert_eq!(usage.completion_tokens, 50); |
| assert_eq!(usage.total_tokens, 150); |
| assert!(usage.prompt_tokens_details.is_some()); |
| assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, Some(25)); |
| } |
|
|
| #[test] |
| fn test_response_without_usage_metadata() { |
| let gemini_resp = json!({ |
| "candidates": [{ |
| "content": {"parts": [{"text": "Hello!"}]}, |
| "finishReason": "STOP" |
| }], |
| "modelVersion": "gemini-2.5-flash", |
| "responseId": "resp_123" |
| }); |
|
|
| let result = transform_openai_response(&gemini_resp, Some("session-123"), 1); |
| assert!(result.usage.is_none()); |
| } |
| } |
|
|