tramex_tools/ai/
openai.rs1use crate::ai::{AIConnector, AIRequest};
4use crate::data::{AdditionalInfos, Trace};
5use crate::errors::{ErrorCode, TramexError};
6
7const SYSTEM_PROMPT: &str = r#"You are a telecom protocol expert specializing in 4G LTE and 5G NR analysis. You are helping a user understand traces captured from an Amarisoft base station (eNB/gNB).
9
10When explaining a trace, structure your response in three sections:
111. **Message Overview**: What this message is, which protocol layer it belongs to, and its role in the signaling flow.
122. **Key Fields**: Explain the important fields and parameters present in the message. Use precise telecom terminology but provide brief clarifications for non-obvious terms.
133. **Protocol Context**: Where this message fits in the typical protocol procedure (e.g. attach, handover, bearer setup). Mention what typically precedes and follows it.
14
15Be concise but technically accurate. Target approximately 200 words. Use markdown formatting for readability."#;
16
17pub struct OpenAIConnector {
19 endpoint: String,
21 model: String,
23}
24
25impl OpenAIConnector {
26 pub fn new() -> Self {
28 Self {
29 endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
30 model: "gpt-4o-mini".to_string(),
31 }
32 }
33
34 pub fn with_model(model: &str) -> Self {
36 Self {
37 endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
38 model: model.to_string(),
39 }
40 }
41
42 fn build_user_message(trace: &Trace) -> String {
44 let mut parts = Vec::new();
45
46 parts.push(format!("Layer: {:?}", trace.layer));
48 parts.push(format!("Timestamp: {}", trace.timestamp));
49
50 match &trace.additional_infos {
52 AdditionalInfos::RRCInfos(info) => {
53 parts.push(format!("Direction: {:?}", info.direction));
54 parts.push(format!("Channel/Message: {}", info.canal_msg));
55 }
56 AdditionalInfos::NASInfos(info) => {
57 parts.push(format!("Direction: {:?}", info.direction));
58 parts.push(format!("Message Type: {}", info.message_type));
59 }
60 AdditionalInfos::NGAPInfos(info) => {
61 parts.push(format!("Direction: {:?}", info.direction));
62 parts.push(format!("Message Type: {}", info.message_type));
63 }
64 AdditionalInfos::GTPUInfos(info) => {
65 parts.push(format!("Direction: {:?}", info.direction));
66 parts.push(format!("Message Type: {}", info.message_type));
67 }
68 AdditionalInfos::PHYInfos(info) => {
69 parts.push(format!("Direction: {:?}", info.direction));
70 parts.push(format!("Channel Type: {:?}", info.channel_type));
71 }
72 AdditionalInfos::None => {}
73 }
74
75 if let Some(text_lines) = &trace.text {
77 parts.push(String::new());
78 parts.push("Raw trace content:".to_string());
79 parts.push("```".to_string());
80 for line in text_lines {
81 parts.push(line.clone());
82 }
83 parts.push("```".to_string());
84 }
85
86 parts.join("\n")
87 }
88}
89
90impl Default for OpenAIConnector {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl AIConnector for OpenAIConnector {
97 fn name(&self) -> &'static str {
98 "OpenAI"
99 }
100
101 fn build_request(&self, trace: &Trace, api_key: &str) -> Result<AIRequest, TramexError> {
102 if api_key.is_empty() {
103 return Err(TramexError::new(
104 "API key is empty. Set it in Settings > AI.".to_string(),
105 ErrorCode::RequestError,
106 ));
107 }
108
109 let user_message = Self::build_user_message(trace);
110
111 let body = serde_json::json!({
112 "model": self.model,
113 "messages": [
114 {
115 "role": "system",
116 "content": SYSTEM_PROMPT
117 },
118 {
119 "role": "user",
120 "content": user_message
121 }
122 ],
123 "temperature": 0.3,
124 "max_tokens": 1024
125 });
126
127 let body_str = serde_json::to_string(&body)
128 .map_err(|e| TramexError::new(format!("Failed to serialize request body: {e}"), ErrorCode::RequestError))?;
129
130 Ok(AIRequest {
131 url: self.endpoint.clone(),
132 headers: vec![
133 ("Authorization".to_string(), format!("Bearer {api_key}")),
134 ("Content-Type".to_string(), "application/json".to_string()),
135 ],
136 body: body_str,
137 })
138 }
139
140 fn parse_response(&self, response_body: &str) -> Result<String, TramexError> {
141 let json: serde_json::Value = serde_json::from_str(response_body)
142 .map_err(|e| TramexError::new(format!("Failed to parse AI response: {e}"), ErrorCode::RequestError))?;
143
144 if let Some(error) = json.get("error") {
146 let msg = error
147 .get("message")
148 .and_then(|m| m.as_str())
149 .or_else(|| error.as_str())
150 .unwrap_or("Unknown API error");
151 return Err(TramexError::new(format!("OpenAI API error: {msg}"), ErrorCode::RequestError));
152 }
153
154 if let Some(msg) = json
156 .get("message")
157 .and_then(|m| m.as_str())
158 .or_else(|| json.get("detail").and_then(|d| d.as_str()))
159 {
160 return Err(TramexError::new(format!("OpenAI API error: {msg}"), ErrorCode::RequestError));
161 }
162
163 json.get("choices")
165 .and_then(|c| c.get(0))
166 .and_then(|c| c.get("message"))
167 .and_then(|m| m.get("content"))
168 .and_then(|c| c.as_str())
169 .map(|s| s.to_string())
170 .ok_or_else(|| {
171 TramexError::new(
172 "Unexpected response format from OpenAI API".to_string(),
173 ErrorCode::RequestError,
174 )
175 })
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn parse_successful_response() {
185 let connector = OpenAIConnector::new();
186 let body = r#"{"choices":[{"message":{"content":"Hello"}}]}"#;
187 assert_eq!(connector.parse_response(body).unwrap(), "Hello");
188 }
189
190 #[test]
191 fn parse_error_with_nested_message() {
192 let connector = OpenAIConnector::new();
193 let body = r#"{"error":{"message":"Invalid API key","type":"invalid_request_error"}}"#;
194 let err = connector.parse_response(body).unwrap_err();
195 assert!(err.get_msg().contains("Invalid API key"));
196 }
197
198 #[test]
199 fn parse_error_with_top_level_message() {
200 let connector = OpenAIConnector::new();
201 let body = r#"{"message":"Unauthorized","request_id":"abc"}"#;
202 let err = connector.parse_response(body).unwrap_err();
203 assert!(err.get_msg().contains("Unauthorized"));
204 }
205}