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