Skip to main content

sim_codec_chat/providers/openai/
decode.rs

1use serde_json::{Map, Value};
2use sim_codec::{DecodeBudget, DecodeLimits, Input, domain_input_text};
3use sim_codec_json::{JsonProjectionMode, json_number_to_u64, project_json_to_expr_budgeted};
4use sim_kernel::{CodecId, Error, Expr, Result, Symbol};
5
6use crate::{
7    model_error_expr, model_response_expr, text_part, usage_record, validate_chat_transcript,
8};
9
10use super::OPENAI_CODEC_ID;
11use super::common::{codec_error, codec_eval_to_codec, map_field};
12
13/// Decodes OpenAI request JSON into a validated chat-transcript expression.
14pub fn decode_openai_request(input: Input) -> Result<Expr> {
15    decode_openai_request_for_codec(OPENAI_CODEC_ID, input)
16}
17
18pub(super) fn decode_openai_request_for_codec(codec: CodecId, input: Input) -> Result<Expr> {
19    let source = domain_input_text(codec, input)?;
20    let mut budget = DecodeBudget::new(DecodeLimits::default());
21    budget.check_input_bytes(codec, source.len())?;
22    let value = serde_json::from_str::<Value>(&source).map_err(|err| codec_error(codec, err))?;
23    let request = value
24        .as_object()
25        .ok_or_else(|| codec_error(codec, "openai request must be a json object"))?;
26    let model = string_member(codec, request, "model")?.to_owned();
27    let (task, messages) = request_task_and_messages(codec, request)?;
28    let mut entries = vec![
29        (Expr::Symbol(Symbol::new("model-request")), Expr::Bool(true)),
30        (Expr::Symbol(Symbol::new("task")), task),
31        (Expr::Symbol(Symbol::new("messages")), Expr::List(messages)),
32        (Expr::Symbol(Symbol::new("model")), Expr::String(model)),
33    ];
34    if let Some(stream) = request.get("stream").and_then(Value::as_bool) {
35        entries.push((Expr::Symbol(Symbol::new("stream")), Expr::Bool(stream)));
36    }
37    if let Some(privacy) = request.get("privacy").and_then(Value::as_str) {
38        entries.push((
39            Expr::Symbol(Symbol::new("privacy")),
40            Expr::String(privacy.to_owned()),
41        ));
42    }
43    push_projected_field(
44        codec,
45        &mut budget,
46        request,
47        &mut entries,
48        "budget",
49        "budget",
50    )?;
51    push_projected_field(
52        codec,
53        &mut budget,
54        request,
55        &mut entries,
56        "max_tokens",
57        "max-tokens",
58    )?;
59    push_projected_field(codec, &mut budget, request, &mut entries, "tools", "tools")?;
60    push_projected_field(
61        codec,
62        &mut budget,
63        request,
64        &mut entries,
65        "tool_choice",
66        "tool-choice",
67    )?;
68    let expr = Expr::Map(entries);
69    validate_chat_transcript(&expr).map_err(|err| codec_eval_to_codec(codec, err))?;
70    Ok(expr)
71}
72
73/// Decodes an OpenAI chat-completion response body into a model-response
74/// transcript, optionally embedding the raw provider JSON.
75pub fn decode_openai_response(
76    runner: Symbol,
77    model: &str,
78    body: &[u8],
79    include_raw: bool,
80) -> Result<Expr> {
81    let mut budget = DecodeBudget::new(DecodeLimits::default());
82    budget.check_input_bytes(OPENAI_CODEC_ID, body.len())?;
83    let value: Value = serde_json::from_slice(body)
84        .map_err(|err| Error::Eval(format!("openai codec returned invalid json: {err}")))?;
85    response_expr_from_json(runner, model, &value, include_raw, &mut budget)
86}
87
88/// Decodes an OpenAI chat-completion SSE body into a single model-response
89/// transcript, optionally embedding the raw provider chunks.
90pub fn decode_openai_stream(
91    runner: Symbol,
92    model: &str,
93    body: &[u8],
94    include_raw: bool,
95) -> Result<Expr> {
96    let mut budget = DecodeBudget::new(DecodeLimits::default());
97    budget.check_input_bytes(OPENAI_CODEC_ID, body.len())?;
98    let text = std::str::from_utf8(body)
99        .map_err(|err| Error::Eval(format!("openai stream is not valid utf-8: {err}")))?;
100    let mut chunks = Vec::new();
101    let mut combined = String::new();
102    let mut usage_source = None;
103    let mut stop_reason = Symbol::new("stop");
104    for line in text.lines() {
105        let line = line.trim();
106        if line.is_empty() || line.starts_with(':') {
107            continue;
108        }
109        let Some(payload) = line.strip_prefix("data:") else {
110            continue;
111        };
112        let payload = payload.trim();
113        if payload == "[DONE]" {
114            continue;
115        }
116        let value: Value = serde_json::from_str(payload)
117            .map_err(|err| Error::Eval(format!("openai stream returned invalid json: {err}")))?;
118        if let Some(error) = error_message(value.as_object()) {
119            return error_response_expr(
120                runner,
121                model,
122                error,
123                include_raw,
124                Some(&value),
125                &mut budget,
126            );
127        }
128        if usage_expr(
129            value.as_object().ok_or_else(|| {
130                Error::Eval("openai stream chunk must be a json object".to_owned())
131            })?,
132        )?
133        .is_some()
134        {
135            usage_source = Some(value.clone());
136        }
137        if let Some(text) = stream_delta_text(&value)? {
138            combined.push_str(text);
139        }
140        if let Some(reason) = stream_finish_reason(&value) {
141            stop_reason = Symbol::new(reason);
142        }
143        chunks.push(value);
144    }
145    if chunks.is_empty() {
146        return Err(Error::Eval(
147            "openai stream did not contain any response chunks".to_owned(),
148        ));
149    }
150    let mut entries =
151        match model_response_expr(runner, model, vec![text_part(&combined)], stop_reason) {
152            Expr::Map(entries) => entries,
153            _ => unreachable!("model_response_expr always returns a map"),
154        };
155    if let Some(source) = usage_source.as_ref()
156        && let Some(object) = source.as_object()
157        && let Some(usage) = usage_expr(object)?
158    {
159        entries.push((Expr::Symbol(Symbol::new("usage")), usage));
160    }
161    if include_raw {
162        let raw = chunks
163            .iter()
164            .map(|chunk| {
165                project_json_to_expr_budgeted(
166                    chunk,
167                    JsonProjectionMode::UntaggedInterop,
168                    OPENAI_CODEC_ID,
169                    &mut budget,
170                    0,
171                )
172            })
173            .collect::<Result<Vec<_>>>()?;
174        entries.push((
175            Expr::Symbol(Symbol::new("raw-provider-response")),
176            Expr::List(raw),
177        ));
178    }
179    Ok(Expr::Map(entries))
180}
181
182fn push_projected_field(
183    codec: CodecId,
184    budget: &mut DecodeBudget,
185    request: &Map<String, Value>,
186    entries: &mut Vec<(Expr, Expr)>,
187    provider_key: &str,
188    transcript_key: &str,
189) -> Result<()> {
190    let Some(value) = request
191        .get(provider_key)
192        .or_else(|| request.get(transcript_key))
193    else {
194        return Ok(());
195    };
196    entries.push((
197        Expr::Symbol(Symbol::new(transcript_key)),
198        project_json_to_expr_budgeted(
199            value,
200            JsonProjectionMode::UntaggedInterop,
201            codec,
202            budget,
203            0,
204        )?,
205    ));
206    Ok(())
207}
208
209fn response_expr_from_json(
210    runner: Symbol,
211    model: &str,
212    value: &Value,
213    include_raw: bool,
214    budget: &mut DecodeBudget,
215) -> Result<Expr> {
216    let response = value
217        .as_object()
218        .ok_or_else(|| Error::Eval("openai response must be a json object".to_owned()))?;
219    if let Some(error) = error_message(Some(response)) {
220        return error_response_expr(runner, model, error, include_raw, Some(value), budget);
221    }
222    let choice = response
223        .get("choices")
224        .and_then(Value::as_array)
225        .and_then(|choices| choices.first())
226        .and_then(Value::as_object)
227        .ok_or_else(|| Error::Eval("openai response missing choices[0]".to_owned()))?;
228    let message = choice
229        .get("message")
230        .and_then(Value::as_object)
231        .ok_or_else(|| Error::Eval("openai response missing choices[0].message".to_owned()))?;
232    let stop_reason = choice
233        .get("finish_reason")
234        .and_then(Value::as_str)
235        .unwrap_or("stop");
236    let mut entries = match model_response_expr(
237        runner,
238        model,
239        message_content(message)?,
240        Symbol::new(stop_reason),
241    ) {
242        Expr::Map(entries) => entries,
243        _ => unreachable!("model_response_expr always returns a map"),
244    };
245    if let Some(usage) = usage_expr(response)? {
246        entries.push((Expr::Symbol(Symbol::new("usage")), usage));
247    }
248    if include_raw {
249        entries.push((
250            Expr::Symbol(Symbol::new("raw-provider-response")),
251            project_json_to_expr_budgeted(
252                value,
253                JsonProjectionMode::UntaggedInterop,
254                OPENAI_CODEC_ID,
255                budget,
256                0,
257            )?,
258        ));
259    }
260    Ok(Expr::Map(entries))
261}
262
263fn error_response_expr(
264    runner: Symbol,
265    model: &str,
266    message: String,
267    include_raw: bool,
268    raw: Option<&Value>,
269    budget: &mut DecodeBudget,
270) -> Result<Expr> {
271    let mut entries = match model_error_expr(runner, model, message) {
272        Expr::Map(entries) => entries,
273        _ => unreachable!("model_error_expr always returns a map"),
274    };
275    if include_raw && let Some(raw) = raw {
276        entries.push((
277            Expr::Symbol(Symbol::new("raw-provider-response")),
278            project_json_to_expr_budgeted(
279                raw,
280                JsonProjectionMode::UntaggedInterop,
281                OPENAI_CODEC_ID,
282                budget,
283                0,
284            )?,
285        ));
286    }
287    Ok(Expr::Map(entries))
288}
289
290fn error_message(object: Option<&Map<String, Value>>) -> Option<String> {
291    let error = object?.get("error")?;
292    match error {
293        Value::String(message) => Some(message.clone()),
294        Value::Object(fields) => fields
295            .get("message")
296            .and_then(Value::as_str)
297            .map(str::to_owned)
298            .or_else(|| {
299                fields
300                    .get("type")
301                    .and_then(Value::as_str)
302                    .map(str::to_owned)
303            }),
304        _ => None,
305    }
306}
307
308fn request_task_and_messages(
309    codec: CodecId,
310    request: &Map<String, Value>,
311) -> Result<(Expr, Vec<Expr>)> {
312    if let Some(messages) = request.get("messages").and_then(Value::as_array) {
313        let (task_message, prior_messages) = messages
314            .split_last()
315            .ok_or_else(|| codec_error(codec, "openai request messages must not be empty"))?;
316        return Ok((
317            Expr::String(message_text(codec, task_message)?),
318            prior_messages
319                .iter()
320                .map(|message| message_expr(codec, message))
321                .collect::<Result<Vec<_>>>()?,
322        ));
323    }
324    let input = request
325        .get("input")
326        .ok_or_else(|| codec_error(codec, "openai request missing input"))?;
327    Ok((Expr::String(input_text_value(codec, input)?), Vec::new()))
328}
329
330fn input_text_value(codec: CodecId, value: &Value) -> Result<String> {
331    match value {
332        Value::String(text) => Ok(text.clone()),
333        _ => Err(codec_error(codec, "openai request input must be a string")),
334    }
335}
336
337fn message_expr(codec: CodecId, value: &Value) -> Result<Expr> {
338    let object = value
339        .as_object()
340        .ok_or_else(|| codec_error(codec, "openai message must be an object"))?;
341    let role = string_member(codec, object, "role")?;
342    Ok(Expr::Map(vec![
343        (
344            Expr::Symbol(Symbol::new("role")),
345            Expr::Symbol(Symbol::new(role)),
346        ),
347        (
348            Expr::Symbol(Symbol::new("content")),
349            Expr::List(content_parts(codec, object.get("content"))?),
350        ),
351    ]))
352}
353
354fn message_text(codec: CodecId, value: &Value) -> Result<String> {
355    let object = value
356        .as_object()
357        .ok_or_else(|| codec_error(codec, "openai task message must be an object"))?;
358    let role = string_member(codec, object, "role")?;
359    if role != "user" {
360        return Err(codec_error(
361            codec,
362            "openai request final message must have role user",
363        ));
364    }
365    let parts = content_parts(codec, object.get("content"))?;
366    parts
367        .iter()
368        .map(text_from_part)
369        .collect::<Result<Vec<_>>>()
370        .map(|items| items.join("\n"))
371}
372
373fn content_parts(codec: CodecId, content: Option<&Value>) -> Result<Vec<Expr>> {
374    match content {
375        Some(Value::String(text)) => Ok(vec![text_part(text)]),
376        Some(Value::Array(parts)) => parts
377            .iter()
378            .map(|part| request_part_from_json(codec, part))
379            .collect(),
380        Some(Value::Null) | None => Ok(Vec::new()),
381        _ => Err(codec_error(
382            codec,
383            "openai message content must be string, array, or null",
384        )),
385    }
386}
387
388fn request_part_from_json(codec: CodecId, value: &Value) -> Result<Expr> {
389    let object = value
390        .as_object()
391        .ok_or_else(|| codec_error(codec, "openai content part must be an object"))?;
392    let kind = object.get("type").and_then(Value::as_str).unwrap_or("text");
393    match kind {
394        "text" => Ok(text_part(
395            object
396                .get("text")
397                .and_then(Value::as_str)
398                .ok_or_else(|| codec_error(codec, "openai text content part missing text"))?,
399        )),
400        other => Err(codec_error(
401            codec,
402            format!("openai content part type {other} is not supported"),
403        )),
404    }
405}
406
407fn message_content(message: &Map<String, Value>) -> Result<Vec<Expr>> {
408    match message.get("content") {
409        Some(Value::String(text)) => Ok(vec![text_part(text)]),
410        Some(Value::Array(parts)) => parts
411            .iter()
412            .map(response_part_from_json)
413            .collect::<Result<Vec<_>>>(),
414        Some(Value::Null) | None => Ok(Vec::new()),
415        _ => Err(Error::Eval(
416            "openai response message content must be string, array, or null".to_owned(),
417        )),
418    }
419}
420
421fn response_part_from_json(value: &Value) -> Result<Expr> {
422    let object = value
423        .as_object()
424        .ok_or_else(|| Error::Eval("openai response content part must be an object".to_owned()))?;
425    let kind = object.get("type").and_then(Value::as_str).unwrap_or("text");
426    match kind {
427        "text" => Ok(text_part(
428            object
429                .get("text")
430                .and_then(Value::as_str)
431                .ok_or_else(|| Error::Eval("openai response text part missing text".to_owned()))?,
432        )),
433        other => Err(Error::Eval(format!(
434            "openai response content part type {other} is not supported"
435        ))),
436    }
437}
438
439fn usage_expr(response: &Map<String, Value>) -> Result<Option<Expr>> {
440    let Some(usage) = response.get("usage").and_then(Value::as_object) else {
441        return Ok(None);
442    };
443    let input = usage.get("prompt_tokens").and_then(json_number_to_u64);
444    let output = usage.get("completion_tokens").and_then(json_number_to_u64);
445    let total = usage.get("total_tokens").and_then(json_number_to_u64);
446    Ok(Some(Expr::Map(usage_record(input, output, total))))
447}
448
449fn stream_delta_text(value: &Value) -> Result<Option<&str>> {
450    let choice = stream_choice(value)?;
451    Ok(choice
452        .and_then(|choice| choice.get("delta"))
453        .and_then(Value::as_object)
454        .and_then(|delta| delta.get("content"))
455        .and_then(Value::as_str))
456}
457
458fn stream_finish_reason(value: &Value) -> Option<&str> {
459    stream_choice(value)
460        .ok()
461        .flatten()
462        .and_then(|choice| choice.get("finish_reason"))
463        .and_then(Value::as_str)
464}
465
466fn stream_choice(value: &Value) -> Result<Option<&Map<String, Value>>> {
467    let object = value
468        .as_object()
469        .ok_or_else(|| Error::Eval("openai stream chunk must be a json object".to_owned()))?;
470    Ok(object
471        .get("choices")
472        .and_then(Value::as_array)
473        .and_then(|choices| choices.first())
474        .and_then(Value::as_object))
475}
476
477fn text_from_part(part: &Expr) -> Result<String> {
478    let Expr::Map(entries) = part else {
479        return Err(Error::Eval(
480            "openai content part transcript must be a map".to_owned(),
481        ));
482    };
483    match map_field(entries, "text")? {
484        Expr::String(text) => Ok(text.clone()),
485        _ => Err(Error::Eval(
486            "openai text content part text field must be a string".to_owned(),
487        )),
488    }
489}
490
491fn string_member<'a>(
492    codec: CodecId,
493    object: &'a Map<String, Value>,
494    name: &str,
495) -> Result<&'a str> {
496    object
497        .get(name)
498        .and_then(Value::as_str)
499        .ok_or_else(|| codec_error(codec, format!("openai request missing string {name}")))
500}