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 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
12/// Encodes a model-request transcript into OpenAI chat-completion request JSON,
13/// applying the model, streaming, and tools settings from `options`.
14pub 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
36/// Encodes a response transcript into OpenAI chat-completion response JSON.
37pub 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
43/// Encodes a response transcript into OpenAI Responses API response JSON,
44/// stamping it with `response_id` and `created_at_ms`.
45pub 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
265// Scalar field readers via the shared namespace-agnostic (bare-symbol OR string
266// key) substrate; the untyped `map_field` path (snapshot-verified) stays as is
267// (OVERLAP9.05).
268fn 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}