Skip to main content

agent_engine/runtime/openai/
types.rs

1//! OpenAI-compatible wire types. Ported from the prototype `openai-runtime` crate.
2//!
3//! Note: the prototype's `StreamEvent` is renamed to `OaiEvent` to avoid clashing
4//! with `crate::runtime::types::StreamEvent`.
5
6use serde::ser::SerializeStruct;
7use serde::{Deserialize, Serialize, Serializer};
8use serde_json::Value;
9use std::fmt;
10
11// ─── Tool definitions (request side) ──────────────────────────────────────────
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ToolDefinition {
15    #[serde(rename = "type")]
16    pub kind: String,
17    pub function: FunctionDefinition,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct FunctionDefinition {
22    pub name: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub description: Option<String>,
25    pub parameters: Value,
26}
27
28impl ToolDefinition {
29    pub fn function(
30        name: impl Into<String>,
31        description: impl Into<String>,
32        parameters: Value,
33    ) -> Self {
34        Self {
35            kind: "function".to_string(),
36            function: FunctionDefinition {
37                name: name.into(),
38                description: Some(description.into()),
39                parameters,
40            },
41        }
42    }
43}
44
45// ─── ToolChoice ──────────────────────────────────────────────────────────────
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ToolChoice {
49    None,
50    Auto,
51    Required,
52    Function(String),
53}
54
55impl Serialize for ToolChoice {
56    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
57        match self {
58            ToolChoice::None => ser.serialize_str("none"),
59            ToolChoice::Auto => ser.serialize_str("auto"),
60            ToolChoice::Required => ser.serialize_str("required"),
61            ToolChoice::Function(name) => {
62                #[derive(Serialize)]
63                struct Named<'a> {
64                    name: &'a str,
65                }
66                let mut s = ser.serialize_struct("ToolChoice", 2)?;
67                s.serialize_field("type", "function")?;
68                s.serialize_field("function", &Named { name })?;
69                s.end()
70            }
71        }
72    }
73}
74
75// ─── Tool calls (response side) ───────────────────────────────────────────────
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct ToolCall {
79    pub id: String,
80    #[serde(rename = "type")]
81    pub kind: String,
82    pub function: FunctionCall,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct FunctionCall {
87    pub name: String,
88    /// Raw JSON string. Do NOT parse mid-stream — only after
89    /// `ToolCallsComplete { truncated: false }`.
90    pub arguments: String,
91}
92
93// ─── ChatMessage ─────────────────────────────────────────────────────────────
94
95/// Chat Completions content parts. Canonical attachment bytes are lowered only
96/// at the provider boundary; Responses maps these same parts to its input types.
97#[derive(Clone, Serialize, Deserialize, PartialEq)]
98#[serde(tag = "type", rename_all = "snake_case")]
99pub enum ChatContentPart {
100    Text { text: String },
101    ImageUrl { image_url: ChatImageUrl },
102    File { file: ChatFile },
103}
104
105impl ChatContentPart {
106    pub fn text(text: impl Into<String>) -> Self {
107        Self::Text { text: text.into() }
108    }
109}
110
111#[derive(Clone, Serialize, Deserialize, PartialEq)]
112pub struct ChatImageUrl {
113    pub url: String,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub detail: Option<String>,
116}
117
118#[derive(Clone, Serialize, Deserialize, PartialEq)]
119pub struct ChatFile {
120    pub filename: String,
121    pub file_data: String,
122}
123
124#[derive(Clone)]
125pub struct ChatMessage {
126    pub role: String,
127
128    /// Existing text-only API. `None` serializes as JSON `null` when there are
129    /// no content parts — required for assistant-with-tool-calls.
130    pub content: Option<String>,
131
132    /// Complete ordered multipart content, including any accompanying text.
133    /// When present this replaces `content` on the wire, not an extra wire key.
134    /// Keep attachment data out of the legacy text accessor as well.
135    pub content_parts: Option<Vec<ChatContentPart>>,
136
137    pub tool_calls: Option<Vec<ToolCall>>,
138    pub tool_call_id: Option<String>,
139    pub name: Option<String>,
140}
141
142// Provider wire values may contain entire private documents, data URIs, and
143// untrusted filenames. Debug is metadata-only even when nested in ChatRequest.
144impl fmt::Debug for ChatContentPart {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::Text { text } => f
148                .debug_struct("Text")
149                .field("text_bytes", &text.len())
150                .finish(),
151            Self::ImageUrl { image_url } => f.debug_tuple("ImageUrl").field(image_url).finish(),
152            Self::File { file } => f.debug_tuple("File").field(file).finish(),
153        }
154    }
155}
156
157impl fmt::Debug for ChatImageUrl {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.debug_struct("ChatImageUrl")
160            .field("url_bytes", &self.url.len())
161            .field("has_detail", &self.detail.is_some())
162            .finish()
163    }
164}
165
166impl fmt::Debug for ChatFile {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        f.debug_struct("ChatFile")
169            .field("filename_bytes", &self.filename.len())
170            .field("file_data_bytes", &self.file_data.len())
171            .finish()
172    }
173}
174
175impl fmt::Debug for ChatMessage {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        let role = match self.role.as_str() {
178            "user" => "user",
179            "assistant" => "assistant",
180            "system" => "system",
181            "developer" => "developer",
182            "tool" => "tool",
183            _ => "other",
184        };
185        f.debug_struct("ChatMessage")
186            .field("role", &role)
187            .field("content_bytes", &self.content.as_ref().map(String::len))
188            .field("content_parts", &self.content_parts)
189            .field("tool_call_count", &self.tool_calls.as_ref().map(Vec::len))
190            .field("has_tool_call_id", &self.tool_call_id.is_some())
191            .field("has_name", &self.name.is_some())
192            .finish()
193    }
194}
195
196impl Serialize for ChatMessage {
197    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
198        let mut s = ser.serialize_struct(
199            "ChatMessage",
200            2 + usize::from(self.tool_calls.is_some())
201                + usize::from(self.tool_call_id.is_some())
202                + usize::from(self.name.is_some()),
203        )?;
204        s.serialize_field("role", &self.role)?;
205        if let Some(parts) = &self.content_parts {
206            s.serialize_field("content", parts)?;
207        } else {
208            s.serialize_field("content", &self.content)?;
209        }
210        if let Some(calls) = &self.tool_calls {
211            s.serialize_field("tool_calls", calls)?;
212        }
213        if let Some(id) = &self.tool_call_id {
214            s.serialize_field("tool_call_id", id)?;
215        }
216        if let Some(name) = &self.name {
217            s.serialize_field("name", name)?;
218        }
219        s.end()
220    }
221}
222
223impl<'de> Deserialize<'de> for ChatMessage {
224    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
225        #[derive(Deserialize)]
226        #[serde(untagged)]
227        enum Content {
228            Text(String),
229            Parts(Vec<ChatContentPart>),
230        }
231        #[derive(Deserialize)]
232        struct WireMessage {
233            role: String,
234            content: Option<Content>,
235            tool_calls: Option<Vec<ToolCall>>,
236            tool_call_id: Option<String>,
237            name: Option<String>,
238        }
239        let wire = WireMessage::deserialize(de)?;
240        let (content, content_parts) = match wire.content {
241            Some(Content::Text(text)) => (Some(text), None),
242            Some(Content::Parts(parts)) => (None, Some(parts)),
243            None => (None, None),
244        };
245        Ok(Self {
246            role: wire.role,
247            content,
248            content_parts,
249            tool_calls: wire.tool_calls,
250            tool_call_id: wire.tool_call_id,
251            name: wire.name,
252        })
253    }
254}
255
256impl ChatMessage {
257    pub fn user(content: impl Into<String>) -> Self {
258        Self {
259            role: "user".into(),
260            content: Some(content.into()),
261            content_parts: None,
262            tool_calls: None,
263            tool_call_id: None,
264            name: None,
265        }
266    }
267    pub fn user_parts(parts: Vec<ChatContentPart>) -> Self {
268        Self {
269            role: "user".into(),
270            content: None,
271            content_parts: Some(parts),
272            tool_calls: None,
273            tool_call_id: None,
274            name: None,
275        }
276    }
277    pub fn system(content: impl Into<String>) -> Self {
278        Self {
279            role: "system".into(),
280            content: Some(content.into()),
281            content_parts: None,
282            tool_calls: None,
283            tool_call_id: None,
284            name: None,
285        }
286    }
287    pub fn assistant(content: impl Into<String>) -> Self {
288        Self {
289            role: "assistant".into(),
290            content: Some(content.into()),
291            content_parts: None,
292            tool_calls: None,
293            tool_call_id: None,
294            name: None,
295        }
296    }
297    pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
298        Self {
299            role: "assistant".into(),
300            content: None,
301            content_parts: None,
302            tool_calls: Some(tool_calls),
303            tool_call_id: None,
304            name: None,
305        }
306    }
307    pub fn tool_result(
308        tool_call_id: impl Into<String>,
309        name: impl Into<String>,
310        content: impl Into<String>,
311    ) -> Self {
312        Self {
313            role: "tool".into(),
314            content: Some(content.into()),
315            content_parts: None,
316            tool_calls: None,
317            tool_call_id: Some(tool_call_id.into()),
318            name: Some(name.into()),
319        }
320    }
321
322    pub fn content(&self) -> Option<&str> {
323        self.content.as_deref()
324    }
325}
326
327// ─── Options + Request ───────────────────────────────────────────────────────
328
329#[derive(Debug, Clone, Default)]
330pub struct ChatOptions {
331    pub max_tokens: Option<u32>,
332    pub temperature: Option<f32>,
333    pub tools: Option<Vec<ToolDefinition>>,
334    pub tool_choice: Option<ToolChoice>,
335}
336
337#[derive(Debug, Clone, Serialize)]
338pub struct StreamOptions {
339    pub include_usage: bool,
340}
341
342#[derive(Debug, Clone, Serialize)]
343pub struct ChatRequest {
344    pub model: String,
345    pub messages: Vec<ChatMessage>,
346    pub stream: bool,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub stream_options: Option<StreamOptions>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub max_tokens: Option<u32>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub temperature: Option<f32>,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub tools: Option<Vec<ToolDefinition>>,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub tool_choice: Option<ToolChoice>,
357}
358
359// ─── Finish reason ───────────────────────────────────────────────────────────
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum FinishReason {
363    Stop,
364    Length,
365    ToolCalls,
366    ContentFilter,
367}
368
369// ─── Stream events (OpenAI-side) ─────────────────────────────────────────────
370
371#[derive(Debug, Clone)]
372#[non_exhaustive]
373pub enum OaiEvent {
374    RoleStart(String),
375    TextDelta(String),
376    ToolCallStart {
377        index: u32,
378        id: String,
379        name: String,
380    },
381    ToolCallArgumentsDelta {
382        index: u32,
383        id: String,
384        delta: String,
385    },
386    ToolCallsComplete {
387        calls: Vec<ToolCall>,
388        truncated: bool,
389    },
390    Usage {
391        prompt_tokens: u32,
392        completion_tokens: u32,
393        cached_tokens: u32,
394    },
395    Warning(String),
396    Done,
397}
398
399// ─── Provider config ─────────────────────────────────────────────────────────
400
401/// Credential-free routing data for one provider/model pair. Deliberately has
402/// no API-key field: credentials are broker-owned and applied broker-side at
403/// request time (see `agent_core::auth::broker`).
404#[derive(Clone, Debug)]
405pub struct ProviderConfig {
406    pub base_url: String,
407    pub model: String,
408    pub provider: String,
409}
410
411#[cfg(test)]
412mod chat_message_tests {
413    use super::*;
414    use serde_json::json;
415
416    #[test]
417    fn legacy_text_and_null_serialization_is_byte_stable() {
418        let call = ToolCall {
419            id: "t1".into(),
420            kind: "function".into(),
421            function: FunctionCall {
422                name: "read".into(),
423                arguments: "{}".into(),
424            },
425        };
426        let cases = vec![
427            (
428                ChatMessage::user("hello"),
429                r#"{"role":"user","content":"hello"}"#,
430            ),
431            (
432                ChatMessage::system("rules"),
433                r#"{"role":"system","content":"rules"}"#,
434            ),
435            (
436                ChatMessage::assistant("answer"),
437                r#"{"role":"assistant","content":"answer"}"#,
438            ),
439            (
440                ChatMessage::assistant_tool_calls(vec![call]),
441                r#"{"role":"assistant","content":null,"tool_calls":[{"id":"t1","type":"function","function":{"name":"read","arguments":"{}"}}]}"#,
442            ),
443            (
444                ChatMessage::tool_result("t1", "read", "done"),
445                r#"{"role":"tool","content":"done","tool_call_id":"t1","name":"read"}"#,
446            ),
447        ];
448        for (message, expected) in cases {
449            assert_eq!(serde_json::to_string(&message).unwrap(), expected);
450            let restored: ChatMessage = serde_json::from_str(expected).unwrap();
451            assert_eq!(restored.content(), message.content());
452            assert!(restored.content_parts.is_none());
453            assert_eq!(serde_json::to_string(&restored).unwrap(), expected);
454        }
455    }
456
457    #[test]
458    fn multipart_serializes_only_as_content_and_round_trips() {
459        let wire = json!({"role":"user","content":[
460            {"type":"text","text":"private text document"},
461            {"type":"image_url","image_url":{"url":"data:image/png;base64,aW1hZ2U=","detail":"high"}},
462            {"type":"file","file":{"filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="}}
463        ]});
464        let mut restored: ChatMessage = serde_json::from_value(wire.clone()).unwrap();
465        assert_eq!(restored.content(), None);
466        assert_eq!(restored.content_parts.as_ref().unwrap().len(), 3);
467        assert_eq!(serde_json::to_value(&restored).unwrap(), wire);
468        restored.content = Some("legacy field is not concatenated or duplicated".into());
469        assert_eq!(serde_json::to_value(restored).unwrap(), wire);
470    }
471
472    #[test]
473    fn debug_is_metadata_only_for_all_attachment_types_and_messages() {
474        let image = ChatImageUrl {
475            url: "data:image/png;base64,IMAGE_PAYLOAD_SENTINEL".into(),
476            detail: Some("PRIVATE_DETAIL_SENTINEL".into()),
477        };
478        let file = ChatFile {
479            filename: "PRIVATE_FILENAME_SENTINEL.pdf".into(),
480            file_data: "data:application/pdf;base64,PDF_PAYLOAD_SENTINEL".into(),
481        };
482        let parts = vec![
483            ChatContentPart::text("TEXT_DOCUMENT_SENTINEL"),
484            ChatContentPart::ImageUrl {
485                image_url: image.clone(),
486            },
487            ChatContentPart::File { file: file.clone() },
488        ];
489        let mut message = ChatMessage::user_parts(parts.clone());
490        message.content = Some("LEGACY_TEXT_SENTINEL".into());
491        message.name = Some("PRIVATE_NAME_SENTINEL".into());
492        message.tool_call_id = Some("PRIVATE_ID_SENTINEL".into());
493        message.tool_calls = Some(vec![ToolCall {
494            id: "PRIVATE_ID_SENTINEL".into(),
495            kind: "function".into(),
496            function: FunctionCall {
497                name: "PRIVATE_NAME_SENTINEL".into(),
498                arguments: "PRIVATE_ARGUMENTS_SENTINEL".into(),
499            },
500        }]);
501        let debug = format!("{image:?} {file:?} {parts:?} {message:?}");
502        assert!(
503            !debug.contains("SENTINEL"),
504            "Debug must not emit private values"
505        );
506        assert!(!debug.contains("data:"));
507        assert!(debug.contains("file_data_bytes"));
508        assert!(debug.contains("text_bytes"));
509        assert!(debug.contains("tool_call_count: Some(1)"));
510    }
511}