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_with_limits(input, DecodeLimits::default())
16}
17
18pub 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
82pub 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
93pub 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
126pub 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
137pub 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}