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