sim_codec_chat/providers/openai/
encode.rs1use 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
12pub 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
35pub 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(super) fn encode_openai_response_for_codec(codec: CodecId, expr: &Expr) -> Result<String> {
43 if !marker_is_true(expr, "model-response") {
44 return Err(codec_error(
45 codec,
46 "openai codec expects a model-response transcript",
47 ));
48 }
49 validate_chat_transcript(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
50 let value = response_json(expr).map_err(|err| codec_eval_to_codec(codec, err))?;
51 serde_json::to_string(&value).map_err(|err| codec_error(codec, err))
52}
53
54fn transcript_messages(expr: &Expr) -> Result<Vec<Value>> {
55 let Expr::Map(entries) = expr else {
56 return Err(Error::Eval(
57 "openai codec expects request transcript as a map".to_owned(),
58 ));
59 };
60 let mut messages = list_field(map_field(entries, "messages")?)?
61 .iter()
62 .map(message_to_json)
63 .collect::<Result<Vec<_>>>()?;
64 messages.push(json!({
65 "role": "user",
66 "content": [{
67 "type": "text",
68 "text": flatten_expr(map_field(entries, "task")?),
69 }],
70 }));
71 Ok(messages)
72}
73
74fn message_to_json(expr: &Expr) -> Result<Value> {
75 let Expr::Map(entries) = expr else {
76 return Err(Error::Eval("openai codec message must be a map".to_owned()));
77 };
78 Ok(json!({
79 "role": symbol_field(entries, "role")?,
80 "content": list_field(map_field(entries, "content")?)?
81 .iter()
82 .map(content_part_to_json)
83 .collect::<Result<Vec<_>>>()?,
84 }))
85}
86
87fn content_part_to_json(expr: &Expr) -> Result<Value> {
88 let Expr::Map(entries) = expr else {
89 return Err(Error::Eval(
90 "openai codec content part must be a map".to_owned(),
91 ));
92 };
93 match symbol_field(entries, "type")?.as_str() {
94 "text" => Ok(json!({
95 "type": "text",
96 "text": string_field(entries, "text")?,
97 })),
98 other => Err(Error::Eval(format!(
99 "openai codec does not support content part type {other}"
100 ))),
101 }
102}
103
104fn response_json(expr: &Expr) -> Result<Value> {
105 let Expr::Map(entries) = expr else {
106 return Err(Error::Eval(
107 "openai codec expects response transcript as a map".to_owned(),
108 ));
109 };
110 let model = string_field(entries, "model")?;
111 let finish_reason = symbol_field(entries, "stop-reason")?;
112 Ok(json!({
113 "id": "chatcmpl-sim",
114 "object": "chat.completion",
115 "created": 0,
116 "model": model,
117 "choices": [{
118 "index": 0,
119 "message": {
120 "role": "assistant",
121 "content": response_text(entries)?,
122 },
123 "finish_reason": finish_reason,
124 }],
125 "usage": response_usage(entries)?,
126 }))
127}
128
129fn response_text(entries: &[(Expr, Expr)]) -> Result<String> {
130 list_field(map_field(entries, "content")?)?
131 .iter()
132 .map(text_content)
133 .collect::<Result<Vec<_>>>()
134 .map(|parts| parts.join(""))
135}
136
137fn text_content(expr: &Expr) -> Result<String> {
138 let Expr::Map(entries) = expr else {
139 return Err(Error::Eval(
140 "openai codec content part must be a map".to_owned(),
141 ));
142 };
143 match symbol_field(entries, "type")?.as_str() {
144 "text" => string_field(entries, "text"),
145 other => Err(Error::Eval(format!(
146 "openai codec does not support content part type {other}"
147 ))),
148 }
149}
150
151fn response_usage(entries: &[(Expr, Expr)]) -> Result<Value> {
152 let Some(usage) = entries.iter().find_map(|(field, value)| match field {
153 Expr::Symbol(symbol) if symbol.name.as_ref() == "usage" => Some(value),
154 _ => None,
155 }) else {
156 return Ok(Value::Null);
157 };
158 let Expr::Map(fields) = usage else {
159 return Err(Error::Eval(
160 "openai codec usage field must be a map".to_owned(),
161 ));
162 };
163 let prompt = optional_u64_field(fields, "input-tokens")?;
164 let completion = optional_u64_field(fields, "output-tokens")?;
165 let total = optional_u64_field(fields, "total-tokens")?.or_else(|| {
166 prompt
167 .zip(completion)
168 .map(|(left, right)| left.saturating_add(right))
169 });
170 Ok(json!({
171 "prompt_tokens": prompt.unwrap_or(0),
172 "completion_tokens": completion.unwrap_or(0),
173 "total_tokens": total.unwrap_or(0),
174 }))
175}