Skip to main content

sim_lib_openai_server/codec_openai/
encode.rs

1use serde_json::{Value, json};
2use sim_codec_chat::{is_model_request_expr, validate_chat_transcript};
3use sim_codec_json::json_number_to_u64;
4use sim_kernel::{CodecId, Error, Expr, Result};
5
6use crate::codec_openai::codec_error;
7use crate::codec_openai::decode::map_field;
8use crate::codec_openai::shapes::OpenAiCodecOptions;
9
10/// Encodes a model-request transcript into OpenAI chat-completion request JSON,
11/// applying the model, streaming, and tools settings from `options`.
12pub fn encode_openai_request(expr: &Expr, options: &OpenAiCodecOptions) -> Result<Vec<u8>> {
13    if !is_model_request_expr(expr) {
14        return Err(Error::Eval(
15            "openai codec expects a model-request transcript".to_owned(),
16        ));
17    }
18    validate_chat_transcript(expr)?;
19    let mut payload = json!({
20        "model": options.model,
21        "stream": options.stream,
22        "messages": transcript_messages(expr)?,
23        "tools": if options.tools { Value::Array(Vec::new()) } else { Value::Null },
24    });
25    if options.stream
26        && let Some(object) = payload.as_object_mut()
27    {
28        object.insert("stream_options".to_owned(), json!({"include_usage": true}));
29    }
30    serde_json::to_vec(&payload)
31        .map_err(|err| Error::Eval(format!("openai codec failed to encode request: {err}")))
32}
33
34/// Encodes a response transcript into OpenAI chat-completion response JSON.
35pub fn encode_openai_response(expr: &Expr) -> Result<Vec<u8>> {
36    let value = response_json(expr)?;
37    serde_json::to_vec(&value)
38        .map_err(|err| Error::Eval(format!("openai codec failed to encode response: {err}")))
39}
40
41/// Encodes a response transcript into OpenAI Responses API response JSON,
42/// stamping it with `response_id` and `created_at_ms`.
43pub fn encode_openai_responses_response(
44    expr: &Expr,
45    response_id: &str,
46    created_at_ms: u64,
47) -> Result<Vec<u8>> {
48    let value = responses_response_json(expr, response_id, created_at_ms)?;
49    serde_json::to_vec(&value).map_err(|err| {
50        Error::Eval(format!(
51            "openai codec failed to encode responses response: {err}"
52        ))
53    })
54}
55
56pub(crate) fn encode_openai_response_for_codec(codec: CodecId, expr: &Expr) -> Result<String> {
57    if !marker_is_true(expr, "model-response") {
58        return Err(codec_error(
59            codec,
60            "openai codec expects a model-response transcript",
61        ));
62    }
63    validate_chat_transcript(expr).map_err(|err| match err {
64        Error::Eval(message) => codec_error(codec, message),
65        other => other,
66    })?;
67    let value = response_json(expr).map_err(|err| match err {
68        Error::Eval(message) => codec_error(codec, message),
69        other => other,
70    })?;
71    serde_json::to_string(&value).map_err(|err| codec_error(codec, err))
72}
73
74fn response_json(expr: &Expr) -> Result<Value> {
75    let Expr::Map(entries) = expr else {
76        return Err(Error::Eval(
77            "openai codec expects response transcript as a map".to_owned(),
78        ));
79    };
80    let model = string_field(entries, "model")?;
81    let finish_reason = symbol_field(entries, "stop-reason")?;
82    Ok(json!({
83        "id": "chatcmpl-sim",
84        "object": "chat.completion",
85        "created": 0,
86        "model": model,
87        "choices": [{
88            "index": 0,
89            "message": {
90                "role": "assistant",
91                "content": response_text(entries)?,
92            },
93            "finish_reason": finish_reason,
94        }],
95        "usage": response_usage(entries)?,
96    }))
97}
98
99fn responses_response_json(expr: &Expr, response_id: &str, created_at_ms: u64) -> Result<Value> {
100    let Expr::Map(entries) = expr else {
101        return Err(Error::Eval(
102            "openai codec expects response transcript as a map".to_owned(),
103        ));
104    };
105    let model = string_field(entries, "model")?;
106    let output_text = response_text(entries)?;
107    Ok(json!({
108        "id": response_id,
109        "object": "response",
110        "created_at": created_at_ms / 1000,
111        "status": "completed",
112        "model": model,
113        "output": [{
114            "type": "message",
115            "status": "completed",
116            "role": "assistant",
117            "content": [{
118                "type": "output_text",
119                "text": output_text,
120                "annotations": [],
121            }],
122        }],
123        "output_text": output_text,
124        "usage": response_usage(entries)?,
125    }))
126}
127
128fn response_text(entries: &[(Expr, Expr)]) -> Result<String> {
129    list_field(map_field(entries, "content")?)?
130        .iter()
131        .map(text_content)
132        .collect::<Result<Vec<_>>>()
133        .map(|parts| parts.join(""))
134}
135
136fn response_usage(entries: &[(Expr, Expr)]) -> Result<Value> {
137    let Some(usage) = entries.iter().find_map(|(field, value)| match field {
138        Expr::Symbol(symbol) if symbol.name.as_ref() == "usage" => Some(value),
139        _ => None,
140    }) else {
141        return Ok(Value::Null);
142    };
143    let Expr::Map(fields) = usage else {
144        return Err(Error::Eval(
145            "openai codec usage field must be a map".to_owned(),
146        ));
147    };
148    let prompt = optional_u64_field(fields, "input-tokens")?;
149    let completion = optional_u64_field(fields, "output-tokens")?;
150    let total =
151        optional_u64_field(fields, "total-tokens")?.or_else(|| match (prompt, completion) {
152            (Some(left), Some(right)) => Some(left + right),
153            _ => None,
154        });
155    Ok(json!({
156        "prompt_tokens": prompt.unwrap_or(0),
157        "completion_tokens": completion.unwrap_or(0),
158        "total_tokens": total.unwrap_or(0),
159    }))
160}
161
162fn transcript_messages(expr: &Expr) -> Result<Vec<Value>> {
163    let Expr::Map(entries) = expr else {
164        return Err(Error::Eval(
165            "openai codec expects request transcript as a map".to_owned(),
166        ));
167    };
168    let mut messages = list_field(map_field(entries, "messages")?)?
169        .iter()
170        .map(message_to_json)
171        .collect::<Result<Vec<_>>>()?;
172    messages.push(json!({
173        "role": "user",
174        "content": [{
175            "type": "text",
176            "text": flatten_expr(map_field(entries, "task")?),
177        }],
178    }));
179    Ok(messages)
180}
181
182fn message_to_json(expr: &Expr) -> Result<Value> {
183    let Expr::Map(entries) = expr else {
184        return Err(Error::Eval("openai codec message must be a map".to_owned()));
185    };
186    Ok(json!({
187        "role": symbol_field(entries, "role")?,
188        "content": list_field(map_field(entries, "content")?)?
189            .iter()
190            .map(content_part_to_json)
191            .collect::<Result<Vec<_>>>()?,
192    }))
193}
194
195fn content_part_to_json(expr: &Expr) -> Result<Value> {
196    let Expr::Map(entries) = expr else {
197        return Err(Error::Eval(
198            "openai codec content part must be a map".to_owned(),
199        ));
200    };
201    match symbol_field(entries, "type")?.as_str() {
202        "text" => Ok(json!({
203            "type": "text",
204            "text": string_field(entries, "text")?,
205        })),
206        other => Err(Error::Eval(format!(
207            "openai codec does not support content part type {other}"
208        ))),
209    }
210}
211
212fn text_content(expr: &Expr) -> Result<String> {
213    let Expr::Map(entries) = expr else {
214        return Err(Error::Eval(
215            "openai codec content part must be a map".to_owned(),
216        ));
217    };
218    match symbol_field(entries, "type")?.as_str() {
219        "text" => string_field(entries, "text"),
220        other => Err(Error::Eval(format!(
221            "openai codec does not support content part type {other}"
222        ))),
223    }
224}
225
226fn optional_u64_field(entries: &[(Expr, Expr)], key: &str) -> Result<Option<u64>> {
227    let Some(value) = entries.iter().find_map(|(field, value)| match field {
228        Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
229        _ => None,
230    }) else {
231        return Ok(None);
232    };
233    match value {
234        Expr::Number(number) => number
235            .canonical
236            .parse::<u64>()
237            .map(Some)
238            .map_err(|err| Error::Eval(format!("openai codec invalid {key}: {err}"))),
239        other => {
240            let json_number = match other {
241                Expr::String(text) => serde_json::from_str::<Value>(text).ok(),
242                _ => None,
243            };
244            json_number
245                .as_ref()
246                .and_then(json_number_to_u64)
247                .ok_or_else(|| Error::Eval(format!("openai codec field {key} must be a number")))
248                .map(Some)
249        }
250    }
251}
252
253fn marker_is_true(expr: &Expr, name: &str) -> bool {
254    let Expr::Map(entries) = expr else {
255        return false;
256    };
257    entries.iter().any(|(key, value)| {
258        matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == name)
259            && matches!(value, Expr::Bool(true))
260    })
261}
262
263fn symbol_field(entries: &[(Expr, Expr)], key: &str) -> Result<String> {
264    match map_field(entries, key)? {
265        Expr::Symbol(symbol) => Ok(symbol.name.as_ref().to_owned()),
266        _ => Err(Error::Eval(format!(
267            "openai codec field {key} must be a symbol"
268        ))),
269    }
270}
271
272fn string_field(entries: &[(Expr, Expr)], key: &str) -> Result<String> {
273    match map_field(entries, key)? {
274        Expr::String(text) => Ok(text.clone()),
275        _ => Err(Error::Eval(format!(
276            "openai codec field {key} must be a string"
277        ))),
278    }
279}
280
281fn list_field(expr: &Expr) -> Result<&[Expr]> {
282    match expr {
283        Expr::List(items) => Ok(items),
284        _ => Err(Error::Eval("openai codec field must be a list".to_owned())),
285    }
286}
287
288fn flatten_expr(expr: &Expr) -> String {
289    match expr {
290        Expr::Nil => "nil".to_owned(),
291        Expr::Bool(flag) => flag.to_string(),
292        Expr::Number(number) => number.canonical.clone(),
293        Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.to_string(),
294        Expr::String(text) => text.clone(),
295        Expr::Bytes(bytes) => format!("{bytes:?}"),
296        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
297            items.iter().map(flatten_expr).collect::<Vec<_>>().join(" ")
298        }
299        Expr::Map(entries) => entries
300            .iter()
301            .map(|(key, value)| format!("{} {}", flatten_expr(key), flatten_expr(value)))
302            .collect::<Vec<_>>()
303            .join(" "),
304        Expr::Call { operator, args } => std::iter::once(flatten_expr(operator))
305            .chain(args.iter().map(flatten_expr))
306            .collect::<Vec<_>>()
307            .join(" "),
308        Expr::Infix {
309            operator,
310            left,
311            right,
312        } => format!(
313            "{} {} {}",
314            flatten_expr(left),
315            operator,
316            flatten_expr(right)
317        ),
318        Expr::Prefix { operator, arg } => format!("{operator} {}", flatten_expr(arg)),
319        Expr::Postfix { operator, arg } => format!("{} {operator}", flatten_expr(arg)),
320        Expr::Quote { expr, .. } | Expr::Annotated { expr, .. } => flatten_expr(expr),
321        Expr::Extension { tag, payload } => format!("{tag} {}", flatten_expr(payload)),
322    }
323}