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(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
76pub 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
91pub 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}