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