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
13pub 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
73pub 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
88pub 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}