Skip to main content

sim_lib_openai_server/codec_openai/
decode.rs

1use serde_json::{Map, Value};
2use sim_codec::Input;
3use sim_codec_chat::{model_response_expr, text_part, usage_record, validate_chat_transcript};
4use sim_codec_json::{JsonProjectionMode, json_number_to_u64, project_json_to_expr};
5use sim_kernel::{CodecId, Error, Expr, Result, Symbol};
6
7use crate::codec_openai::{codec_error, input_text};
8
9/// Decodes OpenAI request JSON into a validated SIM chat-transcript expression.
10pub fn decode_openai_request(input: Input) -> Result<Expr> {
11    decode_openai_request_for_codec(CodecId(0), input)
12}
13
14pub(crate) fn decode_openai_request_for_codec(codec: CodecId, input: Input) -> Result<Expr> {
15    let source = input_text(codec, input)?;
16    let value = serde_json::from_str::<Value>(&source).map_err(|err| codec_error(codec, err))?;
17    let request = value
18        .as_object()
19        .ok_or_else(|| codec_error(codec, "openai request must be a json object"))?;
20    let model = string_member(codec, request, "model")?.to_owned();
21    let (task, messages) = request_task_and_messages(codec, request)?;
22    let mut entries = vec![
23        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
24        (Expr::Symbol(Symbol::new("task")), task),
25        (Expr::Symbol(Symbol::new("messages")), Expr::List(messages)),
26        (Expr::Symbol(Symbol::new("model")), Expr::String(model)),
27    ];
28    if let Some(stream) = request.get("stream").and_then(Value::as_bool) {
29        entries.push((Expr::Symbol(Symbol::new("stream")), Expr::Bool(stream)));
30    }
31    if let Some(privacy) = request.get("privacy").and_then(Value::as_str) {
32        entries.push((
33            Expr::Symbol(Symbol::new("privacy")),
34            Expr::String(privacy.to_owned()),
35        ));
36    }
37    if let Some(budget) = request.get("budget") {
38        entries.push((
39            Expr::Symbol(Symbol::new("budget")),
40            project_json_to_expr(budget, JsonProjectionMode::UntaggedInterop),
41        ));
42    }
43    if let Some(max_tokens) = request
44        .get("max_tokens")
45        .or_else(|| request.get("max-tokens"))
46    {
47        entries.push((
48            Expr::Symbol(Symbol::new("max-tokens")),
49            project_json_to_expr(max_tokens, JsonProjectionMode::UntaggedInterop),
50        ));
51    }
52    if let Some(tools) = request.get("tools") {
53        entries.push((
54            Expr::Symbol(Symbol::new("tools")),
55            project_json_to_expr(tools, JsonProjectionMode::UntaggedInterop),
56        ));
57    }
58    if let Some(choice) = request
59        .get("tool_choice")
60        .or_else(|| request.get("tool-choice"))
61    {
62        entries.push((
63            Expr::Symbol(Symbol::new("tool-choice")),
64            project_json_to_expr(choice, JsonProjectionMode::UntaggedInterop),
65        ));
66    }
67    let expr = Expr::Map(entries);
68    validate_chat_transcript(&expr)?;
69    Ok(expr)
70}
71
72fn request_task_and_messages(
73    codec: CodecId,
74    request: &Map<String, Value>,
75) -> Result<(Expr, Vec<Expr>)> {
76    if let Some(messages) = request.get("messages").and_then(Value::as_array) {
77        let (task_message, prior_messages) = messages
78            .split_last()
79            .ok_or_else(|| codec_error(codec, "openai request messages must not be empty"))?;
80        return Ok((
81            Expr::String(message_text(codec, task_message)?),
82            prior_messages
83                .iter()
84                .map(|message| message_expr(codec, message))
85                .collect::<Result<Vec<_>>>()?,
86        ));
87    }
88    let input = request
89        .get("input")
90        .ok_or_else(|| codec_error(codec, "openai request missing input"))?;
91    Ok((Expr::String(input_text_value(codec, input)?), Vec::new()))
92}
93
94fn input_text_value(codec: CodecId, value: &Value) -> Result<String> {
95    match value {
96        Value::String(text) => Ok(text.clone()),
97        _ => Err(codec_error(codec, "openai request input must be a string")),
98    }
99}
100
101/// Decodes an OpenAI chat-completion response body into a SIM model-response
102/// transcript, attributing it to `runner`/`model` and optionally embedding the
103/// raw provider JSON when `include_raw` is set.
104pub fn decode_openai_response(
105    runner: Symbol,
106    model: &str,
107    body: &[u8],
108    include_raw: bool,
109) -> Result<Expr> {
110    let value: Value = serde_json::from_slice(body)
111        .map_err(|err| Error::Eval(format!("openai codec returned invalid json: {err}")))?;
112    let response = value
113        .as_object()
114        .ok_or_else(|| Error::Eval("openai response must be a json object".to_owned()))?;
115    let choice = response
116        .get("choices")
117        .and_then(Value::as_array)
118        .and_then(|choices| choices.first())
119        .and_then(Value::as_object)
120        .ok_or_else(|| Error::Eval("openai response missing choices[0]".to_owned()))?;
121    let message = choice
122        .get("message")
123        .and_then(Value::as_object)
124        .ok_or_else(|| Error::Eval("openai response missing choices[0].message".to_owned()))?;
125    let stop_reason = choice
126        .get("finish_reason")
127        .and_then(Value::as_str)
128        .unwrap_or("stop");
129    let mut entries = match model_response_expr(
130        runner,
131        model,
132        message_content(message)?,
133        Symbol::new(stop_reason),
134    ) {
135        Expr::Map(entries) => entries,
136        _ => unreachable!("model_response_expr always returns a map"),
137    };
138    if let Some(usage) = usage_expr(response)? {
139        entries.push((Expr::Symbol(Symbol::new("usage")), usage));
140    }
141    if include_raw {
142        entries.push((
143            Expr::Symbol(Symbol::new("raw-provider-response")),
144            project_json_to_expr(&value, JsonProjectionMode::UntaggedInterop),
145        ));
146    }
147    Ok(Expr::Map(entries))
148}
149
150fn message_expr(codec: CodecId, value: &Value) -> Result<Expr> {
151    let object = value
152        .as_object()
153        .ok_or_else(|| codec_error(codec, "openai message must be an object"))?;
154    let role = string_member(codec, object, "role")?;
155    Ok(Expr::Map(vec![
156        (
157            Expr::Symbol(Symbol::new("role")),
158            Expr::Symbol(Symbol::new(role)),
159        ),
160        (
161            Expr::Symbol(Symbol::new("content")),
162            Expr::List(content_parts(codec, object.get("content"))?),
163        ),
164    ]))
165}
166
167fn message_text(codec: CodecId, value: &Value) -> Result<String> {
168    let object = value
169        .as_object()
170        .ok_or_else(|| codec_error(codec, "openai task message must be an object"))?;
171    let role = string_member(codec, object, "role")?;
172    if role != "user" {
173        return Err(codec_error(
174            codec,
175            "openai request final message must have role user",
176        ));
177    }
178    let parts = content_parts(codec, object.get("content"))?;
179    parts
180        .iter()
181        .map(text_from_part)
182        .collect::<Result<Vec<_>>>()
183        .map(|items| items.join("\n"))
184}
185
186fn content_parts(codec: CodecId, content: Option<&Value>) -> Result<Vec<Expr>> {
187    match content {
188        Some(Value::String(text)) => Ok(vec![text_part(text)]),
189        Some(Value::Array(parts)) => parts
190            .iter()
191            .map(|part| request_part_from_json(codec, part))
192            .collect(),
193        Some(Value::Null) | None => Ok(Vec::new()),
194        _ => Err(codec_error(
195            codec,
196            "openai message content must be string, array, or null",
197        )),
198    }
199}
200
201fn request_part_from_json(codec: CodecId, value: &Value) -> Result<Expr> {
202    let object = value
203        .as_object()
204        .ok_or_else(|| codec_error(codec, "openai content part must be an object"))?;
205    let kind = object.get("type").and_then(Value::as_str).unwrap_or("text");
206    match kind {
207        "text" => Ok(text_part(
208            object
209                .get("text")
210                .and_then(Value::as_str)
211                .ok_or_else(|| codec_error(codec, "openai text content part missing text"))?,
212        )),
213        other => Err(codec_error(
214            codec,
215            format!("openai content part type {other} is not supported"),
216        )),
217    }
218}
219
220fn message_content(message: &Map<String, Value>) -> Result<Vec<Expr>> {
221    match message.get("content") {
222        Some(Value::String(text)) => Ok(vec![text_part(text)]),
223        Some(Value::Array(parts)) => parts
224            .iter()
225            .map(response_part_from_json)
226            .collect::<Result<Vec<_>>>(),
227        Some(Value::Null) | None => Ok(Vec::new()),
228        _ => Err(Error::Eval(
229            "openai response message content must be string, array, or null".to_owned(),
230        )),
231    }
232}
233
234fn response_part_from_json(value: &Value) -> Result<Expr> {
235    let object = value
236        .as_object()
237        .ok_or_else(|| Error::Eval("openai response content part must be an object".to_owned()))?;
238    let kind = object.get("type").and_then(Value::as_str).unwrap_or("text");
239    match kind {
240        "text" => Ok(text_part(
241            object
242                .get("text")
243                .and_then(Value::as_str)
244                .ok_or_else(|| Error::Eval("openai response text part missing text".to_owned()))?,
245        )),
246        other => Err(Error::Eval(format!(
247            "openai response content part type {other} is not supported"
248        ))),
249    }
250}
251
252fn usage_expr(response: &Map<String, Value>) -> Result<Option<Expr>> {
253    let Some(usage) = response.get("usage").and_then(Value::as_object) else {
254        return Ok(None);
255    };
256    let input = usage.get("prompt_tokens").and_then(json_number_to_u64);
257    let output = usage.get("completion_tokens").and_then(json_number_to_u64);
258    let total = usage.get("total_tokens").and_then(json_number_to_u64);
259    // OpenAI emits a usage object: keep an (possibly empty) usage map present.
260    Ok(Some(Expr::Map(usage_record(input, output, total))))
261}
262
263fn text_from_part(part: &Expr) -> Result<String> {
264    let Expr::Map(entries) = part else {
265        return Err(Error::Eval(
266            "openai content part transcript must be a map".to_owned(),
267        ));
268    };
269    match map_field(entries, "text")? {
270        Expr::String(text) => Ok(text.clone()),
271        _ => Err(Error::Eval(
272            "openai text content part text field must be a string".to_owned(),
273        )),
274    }
275}
276
277fn string_member<'a>(
278    codec: CodecId,
279    object: &'a Map<String, Value>,
280    name: &str,
281) -> Result<&'a str> {
282    object
283        .get(name)
284        .and_then(Value::as_str)
285        .ok_or_else(|| codec_error(codec, format!("openai request missing string {name}")))
286}
287
288pub(crate) fn map_field<'a>(entries: &'a [(Expr, Expr)], key: &str) -> Result<&'a Expr> {
289    // OpenAI records use unqualified keys, so the guarded entry_field is
290    // equivalent to the old agnostic lookup (verified by the codec snapshot tests).
291    sim_value::access::entry_field(entries, key)
292        .ok_or_else(|| Error::Eval(format!("openai codec missing {key} field")))
293}