Skip to main content

everruns_contracts/
openai_wire.rs

1//! The OpenAI chat-completions JSON shapes, as values Everruns can read and write.
2//!
3//! The `/chat/completions` message and tool JSON is the lingua franca an
4//! embedder already holds: it arrives from that embedder's own HTTP API, sits
5//! in its stored transcripts, or comes out of another SDK. Everruns' drivers
6//! speak [`Message`] and [`ToolDefinition`] instead, and translating between
7//! the two by hand goes wrong in the same places every time — the two assistant
8//! tool-call shapes in circulation, a `tool` message missing its
9//! `tool_call_id`, and multimodal content quietly flattened to its JSON
10//! rendering because the parts array looked like it had nowhere to go.
11//!
12//! This module is that translation, both ways, in one tested place. It converts
13//! the wire shapes only; it never sends anything.
14//!
15//! ```
16//! use everruns_contracts::openai_wire;
17//! use serde_json::json;
18//!
19//! let message = openai_wire::message_from_openai(&json!({
20//!     "role": "user",
21//!     "content": [
22//!         {"type": "text", "text": "what is in this image?"},
23//!         {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
24//!     ],
25//! }))
26//! .expect("a well-formed user message");
27//! assert!(message.content.is_parts(), "the image survives the conversion");
28//! ```
29
30use serde_json::{Map, Value, json};
31use thiserror::Error;
32
33use crate::driver_registry::{LlmContentPart, Message, MessageContent, MessageRole};
34use crate::tool_types::{ToolCall, ToolDefinition};
35
36/// Why a piece of OpenAI-shaped JSON could not be read.
37///
38/// Every variant names the field at fault, because the caller is usually
39/// holding a transcript and needs to know which entry to fix.
40#[derive(Debug, Clone, Error, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum OpenAiWireError {
43    /// A required field is absent.
44    #[error("missing `{field}`")]
45    Missing {
46        /// The field that was expected.
47        field: &'static str,
48    },
49    /// A field is present but not the shape it must be.
50    #[error("`{field}` is not {expected}")]
51    Invalid {
52        /// The field at fault.
53        field: &'static str,
54        /// What that field has to be.
55        expected: &'static str,
56    },
57    /// The `role` is not one the provider contract has.
58    #[error("unsupported message role: {role}")]
59    UnsupportedRole {
60        /// The role as it appeared on the wire.
61        role: String,
62    },
63}
64
65impl From<OpenAiWireError> for crate::error::AgentLoopError {
66    fn from(error: OpenAiWireError) -> Self {
67        crate::error::AgentLoopError::config(error.to_string())
68    }
69}
70
71type Result<T> = std::result::Result<T, OpenAiWireError>;
72
73/// Read one OpenAI chat message into an [`Message`].
74///
75/// Accepts the shapes the wire actually carries:
76///
77/// - `content` as a string, as a parts array (text, `image_url`,
78///   `input_audio`, `file`), or `null` on an assistant message that only calls
79///   tools;
80/// - assistant `tool_calls` in the nested `{"type":"function","function":{…}}`
81///   form, and in the flat `{"id","name","arguments"}` form transcripts often
82///   store;
83/// - `arguments` as the JSON-encoded string the API sends, or as an already
84///   parsed object.
85///
86/// # Errors
87///
88/// When the role is missing or unknown, when a `tool` message has no
89/// `tool_call_id` (the provider will reject it), or when a tool call has no
90/// name.
91pub fn message_from_openai(value: &Value) -> Result<Message> {
92    let object = value.as_object().ok_or(OpenAiWireError::Invalid {
93        field: "message",
94        expected: "an object",
95    })?;
96    let role = object
97        .get("role")
98        .and_then(Value::as_str)
99        .ok_or(OpenAiWireError::Missing { field: "role" })?;
100    let role = match role {
101        "system" | "developer" => MessageRole::System,
102        "user" => MessageRole::User,
103        "assistant" => MessageRole::Assistant,
104        "tool" | "function" => MessageRole::Tool,
105        other => {
106            return Err(OpenAiWireError::UnsupportedRole {
107                role: other.to_owned(),
108            });
109        }
110    };
111
112    let content = match object.get("content") {
113        None | Some(Value::Null) => MessageContent::Text(String::new()),
114        Some(Value::String(text)) => MessageContent::Text(text.clone()),
115        Some(Value::Array(parts)) => {
116            MessageContent::Parts(parts.iter().map(content_part_from_openai).collect())
117        }
118        Some(_) => {
119            return Err(OpenAiWireError::Invalid {
120                field: "content",
121                expected: "a string, an array of parts, or null",
122            });
123        }
124    };
125
126    let mut message = Message {
127        content,
128        ..Message::text(role, "")
129    };
130
131    if let Some(calls) = object.get("tool_calls") {
132        let calls = calls.as_array().ok_or(OpenAiWireError::Invalid {
133            field: "tool_calls",
134            expected: "an array",
135        })?;
136        let parsed = calls
137            .iter()
138            .map(tool_call_from_openai)
139            .collect::<Result<Vec<_>>>()?;
140        if !parsed.is_empty() {
141            message.tool_calls = Some(parsed);
142        }
143    }
144
145    match object.get("tool_call_id").and_then(Value::as_str) {
146        Some(id) => message.tool_call_id = Some(id.to_owned()),
147        // The provider rejects a tool result it cannot correlate, so this is
148        // caught here rather than one round trip later.
149        None if message.role == MessageRole::Tool => {
150            return Err(OpenAiWireError::Missing {
151                field: "tool_call_id",
152            });
153        }
154        None => {}
155    }
156
157    Ok(message)
158}
159
160/// Read a whole OpenAI `messages` array.
161///
162/// # Errors
163///
164/// The first message that cannot be read, so a bad transcript fails on the
165/// entry at fault rather than silently dropping it.
166pub fn messages_from_openai(values: &[Value]) -> Result<Vec<Message>> {
167    values.iter().map(message_from_openai).collect()
168}
169
170/// Write an [`Message`] back out as OpenAI chat JSON.
171///
172/// The inverse of [`message_from_openai`] for everything that has an OpenAI
173/// equivalent: tool calls go out in the nested `function` form the API
174/// expects, and `arguments` as the JSON-encoded string. Everruns' own
175/// additions with no place on this wire — reasoning artifacts, execution
176/// phase, native calls — are left out rather than invented.
177pub fn message_to_openai(message: &Message) -> Value {
178    let mut object = Map::new();
179    object.insert(
180        "role".to_owned(),
181        Value::String(
182            match message.role {
183                MessageRole::System => "system",
184                MessageRole::User => "user",
185                MessageRole::Assistant => "assistant",
186                MessageRole::Tool => "tool",
187            }
188            .to_owned(),
189        ),
190    );
191    let content = match &message.content {
192        MessageContent::Text(text) => Value::String(text.clone()),
193        MessageContent::Parts(parts) => {
194            Value::Array(parts.iter().filter_map(content_part_to_openai).collect())
195        }
196    };
197    object.insert("content".to_owned(), content);
198    if let Some(calls) = &message.tool_calls {
199        object.insert(
200            "tool_calls".to_owned(),
201            Value::Array(calls.iter().map(tool_call_to_openai).collect()),
202        );
203    }
204    if let Some(id) = &message.tool_call_id {
205        object.insert("tool_call_id".to_owned(), Value::String(id.clone()));
206    }
207    Value::Object(object)
208}
209
210/// Read one OpenAI tool definition (`{"type":"function","function":{…}}`, or
211/// the bare function object) into a [`ToolDefinition`].
212///
213/// The result is a [`ToolDefinition::ClientSide`]: a schema Everruns passes to
214/// the model and whose calls come back to the caller, which is what a tool
215/// arriving as JSON always is — the runtime has no implementation to run for
216/// it.
217///
218/// # Errors
219///
220/// When the function has no `name`.
221pub fn tool_from_openai(value: &Value) -> Result<ToolDefinition> {
222    let function = value.get("function").unwrap_or(value);
223    let name = function
224        .get("name")
225        .and_then(Value::as_str)
226        .ok_or(OpenAiWireError::Missing {
227            field: "function.name",
228        })?;
229    let description = function
230        .get("description")
231        .and_then(Value::as_str)
232        .unwrap_or_default();
233    let parameters = function
234        .get("parameters")
235        .cloned()
236        .unwrap_or_else(empty_parameters);
237    Ok(ToolDefinition::function(name, description, parameters))
238}
239
240/// Read a whole OpenAI `tools` array.
241///
242/// # Errors
243///
244/// The first entry that cannot be read.
245pub fn tools_from_openai(values: &[Value]) -> Result<Vec<ToolDefinition>> {
246    values.iter().map(tool_from_openai).collect()
247}
248
249/// Write a [`ToolDefinition`] out as an OpenAI tool entry.
250pub fn tool_to_openai(tool: &ToolDefinition) -> Value {
251    json!({
252        "type": "function",
253        "function": {
254            "name": tool.name(),
255            "description": tool.description(),
256            "parameters": tool.parameters().clone(),
257        },
258    })
259}
260
261/// Read one assistant tool call, in either shape the wire carries.
262///
263/// # Errors
264///
265/// When the call has no name under either shape.
266pub fn tool_call_from_openai(value: &Value) -> Result<ToolCall> {
267    // The nested `function` object wins when present; otherwise the fields are
268    // read flat, which is how transcripts and hand-built calls store them.
269    let function = value.get("function").unwrap_or(value);
270    let name = function
271        .get("name")
272        .and_then(Value::as_str)
273        .or_else(|| value.get("name").and_then(Value::as_str))
274        .ok_or(OpenAiWireError::Missing { field: "name" })?;
275    let arguments = function
276        .get("arguments")
277        .or_else(|| value.get("arguments"))
278        .map(parse_arguments)
279        .unwrap_or_else(|| json!({}));
280    Ok(ToolCall {
281        id: value
282            .get("id")
283            .and_then(Value::as_str)
284            .unwrap_or_default()
285            .to_owned(),
286        name: name.to_owned(),
287        arguments,
288    })
289}
290
291/// Write a [`ToolCall`] out in the nested OpenAI form, with `arguments`
292/// JSON-encoded as the API expects.
293pub fn tool_call_to_openai(call: &ToolCall) -> Value {
294    json!({
295        "id": call.id,
296        "type": "function",
297        "function": {
298            "name": call.name,
299            "arguments": serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_owned()),
300        },
301    })
302}
303
304/// Arguments arrive JSON-encoded from the API and already parsed from callers
305/// that decoded them; a string that is not JSON is kept as a string rather
306/// than discarded.
307fn parse_arguments(value: &Value) -> Value {
308    match value {
309        Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| value.clone()),
310        other => other.clone(),
311    }
312}
313
314/// An unknown part is carried as its text rendering rather than dropped: a
315/// message that loses content silently is worse than one that carries it
316/// plainly.
317fn content_part_from_openai(value: &Value) -> LlmContentPart {
318    let kind = value.get("type").and_then(Value::as_str).unwrap_or("");
319    match kind {
320        "text" | "input_text" | "output_text" => LlmContentPart::text(
321            value
322                .get("text")
323                .and_then(Value::as_str)
324                .unwrap_or_default(),
325        ),
326        "image_url" | "input_image" => {
327            let url = value
328                .pointer("/image_url/url")
329                .or_else(|| value.get("image_url"))
330                .or_else(|| value.get("url"))
331                .and_then(Value::as_str)
332                .unwrap_or_default();
333            LlmContentPart::image(url)
334        }
335        "input_audio" | "audio" => {
336            let url = value
337                .pointer("/input_audio/data")
338                .or_else(|| value.pointer("/audio/url"))
339                .or_else(|| value.get("url"))
340                .and_then(Value::as_str)
341                .unwrap_or_default();
342            LlmContentPart::audio(url)
343        }
344        "file" | "input_file" => {
345            let file = value.get("file").or_else(|| value.get("input_file"));
346            let url = file
347                .and_then(|file| file.get("file_data").or_else(|| file.get("file_url")))
348                .or_else(|| value.get("url"))
349                .and_then(Value::as_str)
350                .unwrap_or_default();
351            let filename = file
352                .and_then(|file| file.get("filename"))
353                .and_then(Value::as_str)
354                .map(str::to_owned);
355            LlmContentPart::file(url, filename)
356        }
357        _ => LlmContentPart::text(
358            value
359                .get("text")
360                .and_then(Value::as_str)
361                .map_or_else(|| value.to_string(), str::to_owned),
362        ),
363    }
364}
365
366fn content_part_to_openai(part: &LlmContentPart) -> Option<Value> {
367    match part {
368        LlmContentPart::Text { text } => Some(json!({"type": "text", "text": text})),
369        LlmContentPart::Image { url } => {
370            Some(json!({"type": "image_url", "image_url": {"url": url}}))
371        }
372        LlmContentPart::Audio { url } => {
373            Some(json!({"type": "input_audio", "input_audio": {"data": url}}))
374        }
375        LlmContentPart::File { url, filename } => {
376            let mut file = Map::new();
377            file.insert("file_data".to_owned(), Value::String(url.clone()));
378            if let Some(filename) = filename {
379                file.insert("filename".to_owned(), Value::String(filename.clone()));
380            }
381            Some(json!({"type": "file", "file": Value::Object(file)}))
382        }
383        LlmContentPart::ProviderOpaque(_) => None,
384    }
385}
386
387/// The schema for a tool that takes no arguments.
388pub(crate) fn empty_parameters() -> Value {
389    json!({"type": "object", "properties": {}})
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn a_plain_exchange_round_trips() {
398        let wire = json!([
399            {"role": "system", "content": "be brief"},
400            {"role": "user", "content": "hi"},
401        ]);
402        let messages = messages_from_openai(wire.as_array().unwrap()).unwrap();
403        assert_eq!(messages[0].role, MessageRole::System);
404        assert_eq!(messages[1].content.to_text(), "hi");
405        let back: Vec<Value> = messages.iter().map(message_to_openai).collect();
406        assert_eq!(Value::Array(back), wire);
407    }
408
409    #[test]
410    fn provider_opaque_content_is_not_written_to_openai_messages() {
411        let message = Message::parts(
412            MessageRole::Assistant,
413            vec![
414                LlmContentPart::ProviderOpaque(crate::message::ProviderOpaqueContent::new(
415                    "anthropic",
416                    json!([{"signature": "PRIVATE-SIGNATURE"}]),
417                )),
418                LlmContentPart::text("answer"),
419            ],
420        );
421
422        let wire = message_to_openai(&message);
423        assert_eq!(wire["content"], json!([{"type": "text", "text": "answer"}]));
424        assert!(!wire.to_string().contains("PRIVATE-SIGNATURE"));
425    }
426
427    #[test]
428    fn developer_is_read_as_a_system_message() {
429        let message = message_from_openai(&json!({"role": "developer", "content": "rules"}))
430            .expect("the newer system role name is still a system message");
431        assert_eq!(message.role, MessageRole::System);
432    }
433
434    #[test]
435    fn both_assistant_tool_call_shapes_are_read() {
436        let nested = message_from_openai(&json!({
437            "role": "assistant",
438            "content": null,
439            "tool_calls": [{
440                "id": "call_1",
441                "type": "function",
442                "function": {"name": "search", "arguments": "{\"q\":\"rust\"}"},
443            }],
444        }))
445        .unwrap();
446        let flat = message_from_openai(&json!({
447            "role": "assistant",
448            "tool_calls": [{"id": "call_1", "name": "search", "arguments": {"q": "rust"}}],
449        }))
450        .unwrap();
451        for message in [&nested, &flat] {
452            let calls = message.tool_calls.as_ref().expect("a tool call");
453            assert_eq!(calls[0].id, "call_1");
454            assert_eq!(calls[0].name, "search");
455            assert_eq!(calls[0].arguments, json!({"q": "rust"}));
456        }
457    }
458
459    #[test]
460    fn tool_call_arguments_go_back_out_json_encoded() {
461        let call = ToolCall {
462            id: "call_1".into(),
463            name: "search".into(),
464            arguments: json!({"q": "rust"}),
465        };
466        let wire = tool_call_to_openai(&call);
467        assert_eq!(
468            wire.pointer("/function/arguments").unwrap(),
469            &json!("{\"q\":\"rust\"}"),
470            "the API takes arguments as a string, not an object"
471        );
472        assert_eq!(tool_call_from_openai(&wire).unwrap(), call);
473    }
474
475    #[test]
476    fn arguments_that_are_not_json_survive_as_text() {
477        let call =
478            tool_call_from_openai(&json!({"name": "note", "arguments": "not json"})).unwrap();
479        assert_eq!(call.arguments, json!("not json"));
480    }
481
482    #[test]
483    fn a_tool_message_without_its_correlation_id_is_refused() {
484        let error = message_from_openai(&json!({"role": "tool", "content": "42"}))
485            .expect_err("the provider would reject this");
486        assert_eq!(
487            error,
488            OpenAiWireError::Missing {
489                field: "tool_call_id"
490            }
491        );
492        let ok = message_from_openai(&json!({
493            "role": "tool", "content": "42", "tool_call_id": "call_1",
494        }))
495        .unwrap();
496        assert_eq!(ok.tool_call_id.as_deref(), Some("call_1"));
497    }
498
499    #[test]
500    fn an_unknown_role_names_itself() {
501        let error = message_from_openai(&json!({"role": "narrator", "content": "…"}))
502            .expect_err("there is no such role");
503        assert_eq!(
504            error,
505            OpenAiWireError::UnsupportedRole {
506                role: "narrator".into()
507            }
508        );
509    }
510
511    #[test]
512    fn multimodal_parts_convert_instead_of_flattening_to_json_text() {
513        let wire = json!({
514            "role": "user",
515            "content": [
516                {"type": "text", "text": "what is this?"},
517                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
518                {"type": "input_audio", "input_audio": {"data": "data:audio/wav;base64,BBBB"}},
519                {"type": "file", "file": {"file_data": "data:application/pdf;base64,CCCC",
520                                          "filename": "report.pdf"}},
521            ],
522        });
523        let message = message_from_openai(&wire).unwrap();
524        let MessageContent::Parts(parts) = &message.content else {
525            panic!("the parts array must stay parts");
526        };
527        assert_eq!(parts.len(), 4);
528        assert_eq!(
529            parts[1],
530            LlmContentPart::image("data:image/png;base64,AAAA")
531        );
532        assert_eq!(
533            parts[3],
534            LlmContentPart::file(
535                "data:application/pdf;base64,CCCC",
536                Some("report.pdf".into())
537            )
538        );
539        assert_eq!(message_to_openai(&message), wire);
540    }
541
542    #[test]
543    fn an_unrecognized_part_keeps_its_content_rather_than_vanishing() {
544        let message = message_from_openai(&json!({
545            "role": "user",
546            "content": [{"type": "video", "url": "https://example.com/clip.mp4"}],
547        }))
548        .unwrap();
549        assert!(
550            message.content.to_text().contains("clip.mp4"),
551            "an unknown part must not silently drop its content"
552        );
553    }
554
555    #[test]
556    fn tool_definitions_read_both_wrapped_and_bare() {
557        let wrapped = json!({
558            "type": "function",
559            "function": {
560                "name": "search",
561                "description": "look things up",
562                "parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
563            },
564        });
565        let tool = tool_from_openai(&wrapped).unwrap();
566        assert_eq!(tool.name(), "search");
567        assert_eq!(tool.description(), "look things up");
568        assert_eq!(tool_to_openai(&tool), wrapped);
569
570        let bare = tool_from_openai(&json!({"name": "ping"})).unwrap();
571        assert_eq!(bare.name(), "ping");
572        assert_eq!(bare.parameters(), &empty_parameters());
573    }
574
575    #[test]
576    fn a_nameless_tool_is_refused() {
577        assert_eq!(
578            tool_from_openai(&json!({"description": "no name"})).unwrap_err(),
579            OpenAiWireError::Missing {
580                field: "function.name"
581            }
582        );
583    }
584}