Skip to main content

sim_codec_chat/providers/openai/
encode.rs

1use serde_json::{Value, json};
2use sim_kernel::{CodecId, Error, Expr, Result};
3
4use crate::{is_model_request_expr, validate_chat_transcript};
5
6use super::OpenAiRequestOptions;
7use super::common::{
8    codec_error, codec_eval_to_codec, flatten_expr, list_field, map_field, marker_is_true,
9    optional_u64_field, string_field, symbol_field,
10};
11
12/// Encodes a model-request transcript into OpenAI chat-completion JSON.
13pub fn encode_openai_request(expr: &Expr, options: &OpenAiRequestOptions) -> Result<Vec<u8>> {
14    if !is_model_request_expr(expr) {
15        return Err(Error::Eval(
16            "openai codec expects a model-request transcript".to_owned(),
17        ));
18    }
19    validate_chat_transcript(expr)?;
20    let mut payload = json!({
21        "model": options.model,
22        "stream": options.stream,
23        "messages": transcript_messages(expr)?,
24        "tools": if options.tools { Value::Array(Vec::new()) } else { Value::Null },
25    });
26    if options.stream
27        && let Some(object) = payload.as_object_mut()
28    {
29        object.insert("stream_options".to_owned(), json!({"include_usage": true}));
30    }
31    serde_json::to_vec(&payload)
32        .map_err(|err| Error::Eval(format!("openai codec failed to encode request: {err}")))
33}
34
35/// Encodes a model-response transcript into OpenAI chat-completion JSON.
36pub fn encode_openai_response(expr: &Expr) -> Result<Vec<u8>> {
37    let value = response_json(expr)?;
38    serde_json::to_vec(&value)
39        .map_err(|err| Error::Eval(format!("openai codec failed to encode response: {err}")))
40}
41
42pub(in crate::providers) fn encode_openai_response_for_codec(
43    codec: CodecId,
44    expr: &Expr,
45) -> Result<String> {
46    if !marker_is_true(expr, "model-response") {
47        return Err(codec_error(
48            codec,
49            "openai codec expects a model-response transcript",
50        ));
51    }
52    validate_chat_transcript(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
53    let value = response_json(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
54    serde_json::to_string(&value).map_err(|err| codec_error(codec, err))
55}
56
57fn transcript_messages(expr: &Expr) -> Result<Vec<Value>> {
58    let Expr::Map(entries) = expr else {
59        return Err(Error::Eval(
60            "openai codec expects request transcript as a map".to_owned(),
61        ));
62    };
63    let mut messages = list_field(map_field(entries, "messages")?)?
64        .iter()
65        .map(message_to_json)
66        .collect::<Result<Vec<_>>>()?;
67    messages.push(json!({
68        "role": "user",
69        "content": [{
70            "type": "text",
71            "text": flatten_expr(map_field(entries, "task")?),
72        }],
73    }));
74    Ok(messages)
75}
76
77fn message_to_json(expr: &Expr) -> Result<Value> {
78    let Expr::Map(entries) = expr else {
79        return Err(Error::Eval("openai codec message must be a map".to_owned()));
80    };
81    Ok(json!({
82        "role": symbol_field(entries, "role")?,
83        "content": list_field(map_field(entries, "content")?)?
84            .iter()
85            .map(content_part_to_json)
86            .collect::<Result<Vec<_>>>()?,
87    }))
88}
89
90fn content_part_to_json(expr: &Expr) -> Result<Value> {
91    let Expr::Map(entries) = expr else {
92        return Err(Error::Eval(
93            "openai codec content part must be a map".to_owned(),
94        ));
95    };
96    match symbol_field(entries, "type")?.as_str() {
97        "text" => Ok(json!({
98            "type": "text",
99            "text": string_field(entries, "text")?,
100        })),
101        other => Err(Error::Eval(format!(
102            "openai codec does not support content part type {other}"
103        ))),
104    }
105}
106
107fn response_json(expr: &Expr) -> Result<Value> {
108    let Expr::Map(entries) = expr else {
109        return Err(Error::Eval(
110            "openai codec expects response transcript as a map".to_owned(),
111        ));
112    };
113    let model = string_field(entries, "model")?;
114    let finish_reason = symbol_field(entries, "stop-reason")?;
115    Ok(json!({
116        "id": "chatcmpl-sim",
117        "object": "chat.completion",
118        "created": 0,
119        "model": model,
120        "choices": [{
121            "index": 0,
122            "message": {
123                "role": "assistant",
124                "content": response_text(entries)?,
125            },
126            "finish_reason": finish_reason,
127        }],
128        "usage": response_usage(entries)?,
129    }))
130}
131
132fn response_text(entries: &[(Expr, Expr)]) -> Result<String> {
133    list_field(map_field(entries, "content")?)?
134        .iter()
135        .map(text_content)
136        .collect::<Result<Vec<_>>>()
137        .map(|parts| parts.join(""))
138}
139
140fn text_content(expr: &Expr) -> Result<String> {
141    let Expr::Map(entries) = expr else {
142        return Err(Error::Eval(
143            "openai codec content part must be a map".to_owned(),
144        ));
145    };
146    match symbol_field(entries, "type")?.as_str() {
147        "text" => string_field(entries, "text"),
148        other => Err(Error::Eval(format!(
149            "openai codec does not support content part type {other}"
150        ))),
151    }
152}
153
154fn response_usage(entries: &[(Expr, Expr)]) -> Result<Value> {
155    let Some(usage) = entries.iter().find_map(|(field, value)| match field {
156        Expr::Symbol(symbol) if symbol.name.as_ref() == "usage" => Some(value),
157        _ => None,
158    }) else {
159        return Ok(Value::Null);
160    };
161    let Expr::Map(fields) = usage else {
162        return Err(Error::Eval(
163            "openai codec usage field must be a map".to_owned(),
164        ));
165    };
166    let prompt = optional_u64_field(fields, "input-tokens")?;
167    let completion = optional_u64_field(fields, "output-tokens")?;
168    let total = optional_u64_field(fields, "total-tokens")?.or_else(|| {
169        prompt
170            .zip(completion)
171            .map(|(left, right)| left.saturating_add(right))
172    });
173    Ok(json!({
174        "prompt_tokens": prompt.unwrap_or(0),
175        "completion_tokens": completion.unwrap_or(0),
176        "total_tokens": total.unwrap_or(0),
177    }))
178}