1use std::collections::HashSet;
2
3use serde_json::{Map, Value, json};
4
5use crate::{
6 anthropic::schema::{Message, MessagesRequest},
7 registry::normalize_incoming_model,
8};
9
10use super::{OpenAiError, OpenAiResponseMetadata, OpenAiSurface};
11
12const MAX_MESSAGES: usize = 1_024;
13const MAX_TOOLS: usize = 128;
14const MAX_TOOL_ARGUMENT_BYTES: usize = 1024 * 1024;
15const MAX_IMAGE_DATA_BYTES: usize = 12 * 1024 * 1024;
16
17const CHAT_FIELDS: &[&str] = &[
18 "model",
19 "messages",
20 "stream",
21 "stream_options",
22 "max_tokens",
23 "max_completion_tokens",
24 "tools",
25 "tool_choice",
26 "reasoning_effort",
27 "n",
28 "parallel_tool_calls",
29];
30
31const RESPONSES_FIELDS: &[&str] = &[
32 "model",
33 "input",
34 "instructions",
35 "stream",
36 "max_output_tokens",
37 "tools",
38 "tool_choice",
39 "parallel_tool_calls",
40 "reasoning",
41 "store",
42];
43
44#[derive(Debug, Clone)]
45pub struct ParsedOpenAiRequest {
46 pub messages: MessagesRequest,
47 pub requested_model: String,
48 pub normalized_model: String,
49 pub stream: bool,
50 pub include_usage: bool,
51 pub response_metadata: OpenAiResponseMetadata,
52}
53
54pub fn extract_model(body: &Value) -> Result<(String, String), OpenAiError> {
55 let object = body.as_object().ok_or_else(|| {
56 OpenAiError::invalid("Request body must be a JSON object", None::<String>)
57 })?;
58 let requested = object
59 .get("model")
60 .and_then(Value::as_str)
61 .filter(|model| !model.is_empty())
62 .ok_or_else(|| OpenAiError::invalid("Missing or invalid 'model'", Some("model")))?
63 .to_string();
64 Ok((requested.clone(), normalize_incoming_model(&requested)))
65}
66
67pub fn parse_request(
68 surface: OpenAiSurface,
69 body: Value,
70 provider: &str,
71 session_id: Option<&str>,
72) -> Result<ParsedOpenAiRequest, OpenAiError> {
73 let (requested_model, normalized_model) = extract_model(&body)?;
74 let object = body
75 .as_object()
76 .expect("extract_model validated request object");
77 reject_fields(
78 object,
79 match surface {
80 OpenAiSurface::ChatCompletions => CHAT_FIELDS,
81 OpenAiSurface::Responses => RESPONSES_FIELDS,
82 },
83 )?;
84 let stream = optional_bool(object, "stream")?.unwrap_or(false);
85 let mut include_usage = false;
86 let (messages, system) = match surface {
87 OpenAiSurface::ChatCompletions => {
88 include_usage = parse_stream_options(object.get("stream_options"), stream)?;
89 parse_chat_messages(object.get("messages"))?
90 }
91 OpenAiSurface::Responses => {
92 parse_responses_input(object.get("input"), object.get("instructions"))?
93 }
94 };
95 if messages.is_empty() {
96 return Err(OpenAiError::invalid(
97 match surface {
98 OpenAiSurface::ChatCompletions => "'messages' must contain at least one message",
99 OpenAiSurface::Responses => "'input' must contain at least one input item",
100 },
101 Some(match surface {
102 OpenAiSurface::ChatCompletions => "messages",
103 OpenAiSurface::Responses => "input",
104 }),
105 ));
106 }
107 if messages.len() > MAX_MESSAGES {
108 return Err(OpenAiError::invalid(
109 format!("Request contains more than {MAX_MESSAGES} messages"),
110 Some(match surface {
111 OpenAiSurface::ChatCompletions => "messages",
112 OpenAiSurface::Responses => "input",
113 }),
114 ));
115 }
116 validate_single_choice(object)?;
117 let parallel_tool_calls = optional_bool(object, "parallel_tool_calls")?;
118 validate_store(surface, object)?;
119 let max_tokens = parse_max_tokens(surface, object)?;
120 let tools = parse_tools(object.get("tools"), surface)?;
121 let mut tool_choice = parse_tool_choice(object.get("tool_choice"), &tools, surface)?;
122 apply_parallel_tool_calls(&mut tool_choice, parallel_tool_calls);
123 let response_metadata = if surface == OpenAiSurface::Responses {
124 OpenAiResponseMetadata {
125 tools: object
126 .get("tools")
127 .and_then(Value::as_array)
128 .cloned()
129 .unwrap_or_default(),
130 tool_choice: object
131 .get("tool_choice")
132 .filter(|value| !value.is_null())
133 .cloned()
134 .unwrap_or_else(|| json!("auto")),
135 }
136 } else {
137 OpenAiResponseMetadata::default()
138 };
139 let effort = parse_effort(surface, object)?;
140 validate_cursor(provider, session_id, stream, &messages, &tools)?;
141
142 let mut extra = Map::new();
143 if !system.is_empty() {
144 extra.insert(
145 "system".to_string(),
146 Value::Array(
147 system
148 .into_iter()
149 .map(|text| json!({"type":"text", "text":text}))
150 .collect(),
151 ),
152 );
153 }
154 if !tools.is_empty() {
155 extra.insert("tools".to_string(), Value::Array(tools));
156 }
157 if let Some(choice) = tool_choice {
158 extra.insert("tool_choice".to_string(), choice);
159 }
160 if let Some(effort) = effort {
161 extra.insert("output_config".to_string(), json!({"effort":effort}));
162 }
163
164 Ok(ParsedOpenAiRequest {
165 messages: MessagesRequest {
166 model: Some(normalized_model.clone()),
167 max_tokens,
168 messages,
169 stream: true,
170 bypass_provider_model_override: false,
171 extra,
172 },
173 requested_model,
174 normalized_model,
175 stream,
176 include_usage,
177 response_metadata,
178 })
179}
180
181fn reject_fields(object: &Map<String, Value>, allowed: &[&str]) -> Result<(), OpenAiError> {
182 for (key, value) in object {
183 if !allowed.contains(&key.as_str()) && !value.is_null() {
184 return Err(OpenAiError::unsupported(key));
185 }
186 }
187 Ok(())
188}
189
190fn optional_bool(
191 object: &Map<String, Value>,
192 key: &'static str,
193) -> Result<Option<bool>, OpenAiError> {
194 match object.get(key) {
195 None | Some(Value::Null) => Ok(None),
196 Some(Value::Bool(value)) => Ok(Some(*value)),
197 Some(_) => Err(OpenAiError::invalid(
198 format!("'{key}' must be a boolean"),
199 Some(key),
200 )),
201 }
202}
203
204fn validate_store(surface: OpenAiSurface, object: &Map<String, Value>) -> Result<(), OpenAiError> {
205 if surface != OpenAiSurface::Responses {
206 return Ok(());
207 }
208 match object.get("store") {
209 None | Some(Value::Null | Value::Bool(false)) => Ok(()),
210 Some(Value::Bool(true)) => Err(OpenAiError::unsupported("store")),
211 Some(_) => Err(OpenAiError::invalid(
212 "'store' must be a boolean",
213 Some("store"),
214 )),
215 }
216}
217
218fn parse_stream_options(value: Option<&Value>, stream: bool) -> Result<bool, OpenAiError> {
219 let Some(value) = value.filter(|value| !value.is_null()) else {
220 return Ok(false);
221 };
222 if !stream {
223 return Err(OpenAiError::invalid(
224 "'stream_options' is only supported when 'stream' is true",
225 Some("stream_options"),
226 ));
227 }
228 let object = value.as_object().ok_or_else(|| {
229 OpenAiError::invalid("'stream_options' must be an object", Some("stream_options"))
230 })?;
231 reject_fields(object, &["include_usage"])?;
232 optional_bool(object, "include_usage").map(|value| value.unwrap_or(false))
233}
234
235fn validate_single_choice(object: &Map<String, Value>) -> Result<(), OpenAiError> {
236 match object.get("n") {
237 None | Some(Value::Null) => Ok(()),
238 Some(Value::Number(number)) if number.as_u64() == Some(1) => Ok(()),
239 Some(Value::Number(_)) => Err(OpenAiError::unsupported("n")),
240 Some(_) => Err(OpenAiError::invalid("'n' must be an integer", Some("n"))),
241 }
242}
243
244fn apply_parallel_tool_calls(tool_choice: &mut Option<Value>, parallel_tool_calls: Option<bool>) {
245 let Some(parallel_tool_calls) = parallel_tool_calls else {
246 return;
247 };
248 let choice = tool_choice.get_or_insert_with(|| json!({"type":"auto"}));
249 choice
250 .as_object_mut()
251 .expect("translated tool choice is an object")
252 .insert(
253 "disable_parallel_tool_use".to_string(),
254 Value::Bool(!parallel_tool_calls),
255 );
256}
257
258fn parse_max_tokens(
259 surface: OpenAiSurface,
260 object: &Map<String, Value>,
261) -> Result<Option<u32>, OpenAiError> {
262 let (primary, alternate) = match surface {
263 OpenAiSurface::ChatCompletions => ("max_completion_tokens", Some("max_tokens")),
264 OpenAiSurface::Responses => ("max_output_tokens", None),
265 };
266 if let Some(alternate) = alternate
267 && object.get(primary).is_some_and(|value| !value.is_null())
268 && object.get(alternate).is_some_and(|value| !value.is_null())
269 {
270 return Err(OpenAiError::invalid(
271 format!("'{primary}' and '{alternate}' cannot both be set"),
272 Some(primary),
273 ));
274 }
275 let (key, value) = object
276 .get(primary)
277 .filter(|value| !value.is_null())
278 .map(|value| (primary, value))
279 .or_else(|| {
280 alternate.and_then(|key| {
281 object
282 .get(key)
283 .filter(|value| !value.is_null())
284 .map(|value| (key, value))
285 })
286 })
287 .unwrap_or((primary, &Value::Null));
288 if value.is_null() {
289 return Ok(None);
290 }
291 let value = value.as_u64().filter(|value| *value > 0).ok_or_else(|| {
292 OpenAiError::invalid(format!("'{key}' must be a positive integer"), Some(key))
293 })?;
294 u32::try_from(value)
295 .map(Some)
296 .map_err(|_| OpenAiError::invalid(format!("'{key}' is too large"), Some(key)))
297}
298
299fn parse_effort(
300 surface: OpenAiSurface,
301 object: &Map<String, Value>,
302) -> Result<Option<String>, OpenAiError> {
303 let value = match surface {
304 OpenAiSurface::ChatCompletions => object.get("reasoning_effort"),
305 OpenAiSurface::Responses => {
306 let Some(reasoning) = object.get("reasoning").filter(|value| !value.is_null()) else {
307 return Ok(None);
308 };
309 let reasoning = reasoning.as_object().ok_or_else(|| {
310 OpenAiError::invalid("'reasoning' must be an object", Some("reasoning"))
311 })?;
312 reject_fields(reasoning, &["effort"])?;
313 reasoning.get("effort")
314 }
315 };
316 let Some(value) = value.filter(|value| !value.is_null()) else {
317 return Ok(None);
318 };
319 let effort = value.as_str().ok_or_else(|| {
320 OpenAiError::invalid(
321 "Reasoning effort must be a string",
322 Some(match surface {
323 OpenAiSurface::ChatCompletions => "reasoning_effort",
324 OpenAiSurface::Responses => "reasoning.effort",
325 }),
326 )
327 })?;
328 match effort {
329 "low" | "medium" | "high" | "xhigh" | "max" => Ok(Some(effort.to_string())),
330 _ => Err(OpenAiError::invalid(
331 format!("Unsupported reasoning effort: '{effort}'"),
332 Some(match surface {
333 OpenAiSurface::ChatCompletions => "reasoning_effort",
334 OpenAiSurface::Responses => "reasoning.effort",
335 }),
336 )),
337 }
338}
339
340fn parse_chat_messages(value: Option<&Value>) -> Result<(Vec<Message>, Vec<String>), OpenAiError> {
341 let messages = value
342 .and_then(Value::as_array)
343 .ok_or_else(|| OpenAiError::invalid("Missing or invalid 'messages'", Some("messages")))?;
344 let mut out = Vec::new();
345 let mut system = Vec::new();
346 let mut calls = HashSet::new();
347 for (index, message) in messages.iter().enumerate() {
348 let param = format!("messages[{index}]");
349 let object = message
350 .as_object()
351 .ok_or_else(|| OpenAiError::invalid("Each message must be an object", Some(¶m)))?;
352 reject_nested_fields(
353 object,
354 &[
355 "role",
356 "content",
357 "name",
358 "tool_calls",
359 "tool_call_id",
360 "reasoning_content",
361 ],
362 ¶m,
363 )?;
364 let role = required_string(object, "role", &format!("{param}.role"))?;
365 match role.as_str() {
366 "system" | "developer" => {
367 system.push(content_text(
368 object.get("content"),
369 &format!("{param}.content"),
370 )?);
371 }
372 "user" => out.push(Message {
373 role: "user".to_string(),
374 content: parse_content(object.get("content"), &format!("{param}.content"), true)?,
375 }),
376 "assistant" => {
377 let mut blocks = Vec::new();
378 if let Some(reasoning) = object
379 .get("reasoning_content")
380 .filter(|value| !value.is_null())
381 {
382 let reasoning = reasoning.as_str().ok_or_else(|| {
383 OpenAiError::invalid(
384 "'reasoning_content' must be a string",
385 Some(format!("{param}.reasoning_content")),
386 )
387 })?;
388 blocks.push(json!({"type":"thinking", "thinking":reasoning}));
389 }
390 append_text_blocks(
391 &mut blocks,
392 object.get("content"),
393 &format!("{param}.content"),
394 )?;
395 parse_chat_tool_calls(object.get("tool_calls"), ¶m, &mut blocks, &mut calls)?;
396 if blocks.is_empty() {
397 return Err(OpenAiError::invalid(
398 "Assistant message requires content or tool calls",
399 Some(format!("{param}.content")),
400 ));
401 }
402 out.push(Message {
403 role: "assistant".to_string(),
404 content: Value::Array(blocks),
405 });
406 }
407 "tool" => {
408 let id = required_string(object, "tool_call_id", &format!("{param}.tool_call_id"))?;
409 if !calls.remove(&id) {
410 return Err(OpenAiError::invalid(
411 format!("Tool result references unknown call '{id}'"),
412 Some(format!("{param}.tool_call_id")),
413 ));
414 }
415 let result = json!({
416 "type":"tool_result",
417 "tool_use_id":id,
418 "content":content_text(object.get("content"), &format!("{param}.content"))?,
419 });
420 push_tool_result(&mut out, result);
421 }
422 _ => {
423 return Err(OpenAiError::invalid(
424 format!("Unsupported message role: '{role}'"),
425 Some(format!("{param}.role")),
426 ));
427 }
428 }
429 }
430 Ok((out, system))
431}
432
433fn parse_responses_input(
434 value: Option<&Value>,
435 instructions: Option<&Value>,
436) -> Result<(Vec<Message>, Vec<String>), OpenAiError> {
437 let mut system = Vec::new();
438 if let Some(instructions) = instructions.filter(|value| !value.is_null()) {
439 system.push(
440 instructions
441 .as_str()
442 .filter(|text| !text.is_empty())
443 .ok_or_else(|| {
444 OpenAiError::invalid(
445 "'instructions' must be a non-empty string",
446 Some("instructions"),
447 )
448 })?
449 .to_string(),
450 );
451 }
452 let Some(value) = value else {
453 return Err(OpenAiError::invalid("Missing 'input'", Some("input")));
454 };
455 if let Some(text) = value.as_str() {
456 if text.is_empty() {
457 return Err(OpenAiError::invalid(
458 "'input' must not be empty",
459 Some("input"),
460 ));
461 }
462 return Ok((
463 vec![Message {
464 role: "user".to_string(),
465 content: Value::String(text.to_string()),
466 }],
467 system,
468 ));
469 }
470 let items = value
471 .as_array()
472 .ok_or_else(|| OpenAiError::invalid("'input' must be a string or array", Some("input")))?;
473 let mut out = Vec::new();
474 let mut calls = HashSet::new();
475 for (index, item) in items.iter().enumerate() {
476 let param = format!("input[{index}]");
477 let object = item
478 .as_object()
479 .ok_or_else(|| OpenAiError::invalid("Input items must be objects", Some(¶m)))?;
480 let kind = object
481 .get("type")
482 .and_then(Value::as_str)
483 .unwrap_or("message");
484 match kind {
485 "message" => {
486 reject_nested_fields(object, &["type", "role", "content", "id", "status"], ¶m)?;
487 let role = required_string(object, "role", &format!("{param}.role"))?;
488 let content = parse_responses_message_content(
489 object.get("content"),
490 &format!("{param}.content"),
491 )?;
492 match role.as_str() {
493 "system" | "developer" => system.push(blocks_text(&content)),
494 "user" | "assistant" => out.push(Message {
495 role,
496 content: Value::Array(content),
497 }),
498 _ => {
499 return Err(OpenAiError::invalid(
500 format!("Unsupported input message role: '{role}'"),
501 Some(format!("{param}.role")),
502 ));
503 }
504 }
505 }
506 "function_call" => {
507 reject_nested_fields(
508 object,
509 &["type", "id", "call_id", "name", "arguments", "status"],
510 ¶m,
511 )?;
512 let id = object
513 .get("call_id")
514 .or_else(|| object.get("id"))
515 .and_then(Value::as_str)
516 .filter(|id| !id.is_empty())
517 .ok_or_else(|| {
518 OpenAiError::invalid(
519 "Function call requires 'call_id'",
520 Some(format!("{param}.call_id")),
521 )
522 })?
523 .to_string();
524 if !calls.insert(id.clone()) {
525 return Err(OpenAiError::invalid(
526 format!("Duplicate function call id '{id}'"),
527 Some(format!("{param}.call_id")),
528 ));
529 }
530 let name = required_string(object, "name", &format!("{param}.name"))?;
531 let input =
532 parse_arguments(object.get("arguments"), &format!("{param}.arguments"))?;
533 out.push(Message {
534 role: "assistant".to_string(),
535 content: json!([{"type":"tool_use", "id":id, "name":name, "input":input}]),
536 });
537 }
538 "function_call_output" => {
539 reject_nested_fields(
540 object,
541 &["type", "id", "call_id", "output", "status"],
542 ¶m,
543 )?;
544 let id = required_string(object, "call_id", &format!("{param}.call_id"))?;
545 if !calls.remove(&id) {
546 return Err(OpenAiError::invalid(
547 format!("Function output references unknown call '{id}'"),
548 Some(format!("{param}.call_id")),
549 ));
550 }
551 let output = object.get("output").ok_or_else(|| {
552 OpenAiError::invalid(
553 "Function output requires 'output'",
554 Some(format!("{param}.output")),
555 )
556 })?;
557 let content = match output {
558 Value::String(text) => Value::String(text.clone()),
559 value => Value::String(value.to_string()),
560 };
561 push_tool_result(
562 &mut out,
563 json!({"type":"tool_result", "tool_use_id":id, "content":content}),
564 );
565 }
566 _ => return Err(OpenAiError::unsupported(format!("{param}.type"))),
567 }
568 }
569 Ok((out, system))
570}
571
572fn parse_chat_tool_calls(
573 value: Option<&Value>,
574 parent: &str,
575 blocks: &mut Vec<Value>,
576 calls: &mut HashSet<String>,
577) -> Result<(), OpenAiError> {
578 let Some(value) = value.filter(|value| !value.is_null()) else {
579 return Ok(());
580 };
581 let items = value.as_array().ok_or_else(|| {
582 OpenAiError::invalid(
583 "'tool_calls' must be an array",
584 Some(format!("{parent}.tool_calls")),
585 )
586 })?;
587 for (index, item) in items.iter().enumerate() {
588 let param = format!("{parent}.tool_calls[{index}]");
589 let object = item
590 .as_object()
591 .ok_or_else(|| OpenAiError::invalid("Tool calls must be objects", Some(¶m)))?;
592 reject_nested_fields(object, &["id", "type", "function"], ¶m)?;
593 if object.get("type").and_then(Value::as_str) != Some("function") {
594 return Err(OpenAiError::unsupported(format!("{param}.type")));
595 }
596 let id = required_string(object, "id", &format!("{param}.id"))?;
597 if !calls.insert(id.clone()) {
598 return Err(OpenAiError::invalid(
599 format!("Duplicate tool call id '{id}'"),
600 Some(format!("{param}.id")),
601 ));
602 }
603 let function = object
604 .get("function")
605 .and_then(Value::as_object)
606 .ok_or_else(|| {
607 OpenAiError::invalid(
608 "Tool call requires a function object",
609 Some(format!("{param}.function")),
610 )
611 })?;
612 reject_nested_fields(
613 function,
614 &["name", "arguments"],
615 &format!("{param}.function"),
616 )?;
617 let name = required_string(function, "name", &format!("{param}.function.name"))?;
618 let input = parse_arguments(
619 function.get("arguments"),
620 &format!("{param}.function.arguments"),
621 )?;
622 blocks.push(json!({"type":"tool_use", "id":id, "name":name, "input":input}));
623 }
624 Ok(())
625}
626
627fn parse_arguments(value: Option<&Value>, param: &str) -> Result<Value, OpenAiError> {
628 let arguments = value.and_then(Value::as_str).ok_or_else(|| {
629 OpenAiError::invalid("Function arguments must be a JSON string", Some(param))
630 })?;
631 if arguments.len() > MAX_TOOL_ARGUMENT_BYTES {
632 return Err(OpenAiError::invalid(
633 "Function arguments are too large",
634 Some(param),
635 ));
636 }
637 let value: Value = serde_json::from_str(arguments).map_err(|error| {
638 OpenAiError::invalid(
639 format!("Function arguments are invalid JSON: {error}"),
640 Some(param),
641 )
642 })?;
643 if !value.is_object() {
644 return Err(OpenAiError::invalid(
645 "Function arguments must decode to an object",
646 Some(param),
647 ));
648 }
649 Ok(value)
650}
651
652fn parse_tools(value: Option<&Value>, surface: OpenAiSurface) -> Result<Vec<Value>, OpenAiError> {
653 let Some(value) = value.filter(|value| !value.is_null()) else {
654 return Ok(Vec::new());
655 };
656 let tools = value
657 .as_array()
658 .ok_or_else(|| OpenAiError::invalid("'tools' must be an array", Some("tools")))?;
659 if tools.len() > MAX_TOOLS {
660 return Err(OpenAiError::invalid(
661 format!("'tools' cannot contain more than {MAX_TOOLS} entries"),
662 Some("tools"),
663 ));
664 }
665 let mut names = HashSet::new();
666 let mut out = Vec::new();
667 for (index, tool) in tools.iter().enumerate() {
668 let param = format!("tools[{index}]");
669 let object = tool
670 .as_object()
671 .ok_or_else(|| OpenAiError::invalid("Tools must be objects", Some(¶m)))?;
672 if object.get("type").and_then(Value::as_str) != Some("function") {
673 return Err(OpenAiError::unsupported(format!("{param}.type")));
674 }
675 let function = match surface {
676 OpenAiSurface::ChatCompletions => {
677 reject_nested_fields(object, &["type", "function"], ¶m)?;
678 object
679 .get("function")
680 .and_then(Value::as_object)
681 .ok_or_else(|| {
682 OpenAiError::invalid(
683 "Function tool requires a function object",
684 Some(format!("{param}.function")),
685 )
686 })?
687 }
688 OpenAiSurface::Responses => object,
689 };
690 let function_param = match surface {
691 OpenAiSurface::ChatCompletions => format!("{param}.function"),
692 OpenAiSurface::Responses => param.clone(),
693 };
694 reject_nested_fields(
695 function,
696 &["type", "name", "description", "parameters", "strict"],
697 &function_param,
698 )?;
699 if function.get("strict").is_some_and(|value| !value.is_null()) {
700 return Err(OpenAiError::unsupported(format!("{function_param}.strict")));
701 }
702 let name = required_string(function, "name", &format!("{function_param}.name"))?;
703 if !names.insert(name.clone()) {
704 return Err(OpenAiError::invalid(
705 format!("Duplicate tool name '{name}'"),
706 Some(format!("{function_param}.name")),
707 ));
708 }
709 let schema = function
710 .get("parameters")
711 .cloned()
712 .unwrap_or_else(|| json!({"type":"object"}));
713 if !schema.is_object() {
714 return Err(OpenAiError::invalid(
715 "Function parameters must be an object",
716 Some(format!("{function_param}.parameters")),
717 ));
718 }
719 let mut translated = Map::from_iter([
720 ("name".to_string(), Value::String(name)),
721 ("input_schema".to_string(), schema),
722 ]);
723 if let Some(description) = function.get("description").filter(|value| !value.is_null()) {
724 translated.insert(
725 "description".to_string(),
726 Value::String(
727 description
728 .as_str()
729 .ok_or_else(|| {
730 OpenAiError::invalid(
731 "Function description must be a string",
732 Some(format!("{function_param}.description")),
733 )
734 })?
735 .to_string(),
736 ),
737 );
738 }
739 out.push(Value::Object(translated));
740 }
741 Ok(out)
742}
743
744fn parse_tool_choice(
745 value: Option<&Value>,
746 tools: &[Value],
747 surface: OpenAiSurface,
748) -> Result<Option<Value>, OpenAiError> {
749 let Some(value) = value.filter(|value| !value.is_null()) else {
750 return Ok(None);
751 };
752 let translated = if let Some(choice) = value.as_str() {
753 match choice {
754 "auto" => json!({"type":"auto"}),
755 "none" => json!({"type":"none"}),
756 "required" => json!({"type":"any"}),
757 _ => return Err(OpenAiError::unsupported("tool_choice")),
758 }
759 } else {
760 let object = value.as_object().ok_or_else(|| {
761 OpenAiError::invalid(
762 "'tool_choice' must be a string or object",
763 Some("tool_choice"),
764 )
765 })?;
766 let name = match surface {
767 OpenAiSurface::ChatCompletions => {
768 if object.get("type").and_then(Value::as_str) != Some("function") {
769 return Err(OpenAiError::unsupported("tool_choice.type"));
770 }
771 object
772 .get("function")
773 .and_then(Value::as_object)
774 .and_then(|function| function.get("name"))
775 .and_then(Value::as_str)
776 }
777 OpenAiSurface::Responses => {
778 if object.get("type").and_then(Value::as_str) != Some("function") {
779 return Err(OpenAiError::unsupported("tool_choice.type"));
780 }
781 object.get("name").and_then(Value::as_str)
782 }
783 }
784 .filter(|name| !name.is_empty())
785 .ok_or_else(|| OpenAiError::invalid("Tool choice requires a name", Some("tool_choice")))?;
786 if !tools
787 .iter()
788 .any(|tool| tool.get("name").and_then(Value::as_str) == Some(name))
789 {
790 return Err(OpenAiError::invalid(
791 format!("Tool choice references unknown tool '{name}'"),
792 Some("tool_choice"),
793 ));
794 }
795 json!({"type":"tool", "name":name})
796 };
797 Ok(Some(translated))
798}
799
800fn parse_content(
801 value: Option<&Value>,
802 param: &str,
803 allow_images: bool,
804) -> Result<Value, OpenAiError> {
805 match value {
806 Some(Value::String(text)) if !text.is_empty() => Ok(Value::String(text.clone())),
807 Some(Value::Array(parts)) if !parts.is_empty() => {
808 let mut out = Vec::new();
809 for (index, part) in parts.iter().enumerate() {
810 out.push(parse_chat_content_part(
811 part,
812 &format!("{param}[{index}]"),
813 allow_images,
814 )?);
815 }
816 Ok(Value::Array(out))
817 }
818 _ => Err(OpenAiError::invalid(
819 "Message content must not be empty",
820 Some(param),
821 )),
822 }
823}
824
825fn append_text_blocks(
826 out: &mut Vec<Value>,
827 value: Option<&Value>,
828 param: &str,
829) -> Result<(), OpenAiError> {
830 match value {
831 None | Some(Value::Null) => Ok(()),
832 Some(Value::String(text)) if text.is_empty() => Ok(()),
833 Some(Value::String(text)) => {
834 out.push(json!({"type":"text", "text":text}));
835 Ok(())
836 }
837 Some(Value::Array(parts)) => {
838 for (index, part) in parts.iter().enumerate() {
839 let block = parse_chat_content_part(part, &format!("{param}[{index}]"), false)?;
840 out.push(block);
841 }
842 Ok(())
843 }
844 _ => Err(OpenAiError::invalid(
845 "Invalid assistant content",
846 Some(param),
847 )),
848 }
849}
850
851fn parse_chat_content_part(
852 part: &Value,
853 param: &str,
854 allow_images: bool,
855) -> Result<Value, OpenAiError> {
856 let object = part
857 .as_object()
858 .ok_or_else(|| OpenAiError::invalid("Content parts must be objects", Some(param)))?;
859 match object.get("type").and_then(Value::as_str) {
860 Some("text") => Ok(json!({
861 "type":"text",
862 "text":required_string(object, "text", &format!("{param}.text"))?,
863 })),
864 Some("image_url") if allow_images => {
865 let image = object
866 .get("image_url")
867 .and_then(Value::as_object)
868 .ok_or_else(|| {
869 OpenAiError::invalid(
870 "Image content requires 'image_url'",
871 Some(format!("{param}.image_url")),
872 )
873 })?;
874 image_url_block(
875 image.get("url").and_then(Value::as_str).ok_or_else(|| {
876 OpenAiError::invalid(
877 "Image URL must be a string",
878 Some(format!("{param}.image_url.url")),
879 )
880 })?,
881 &format!("{param}.image_url.url"),
882 )
883 }
884 _ => Err(OpenAiError::unsupported(format!("{param}.type"))),
885 }
886}
887
888fn parse_responses_message_content(
889 value: Option<&Value>,
890 param: &str,
891) -> Result<Vec<Value>, OpenAiError> {
892 if let Some(text) = value.and_then(Value::as_str) {
893 return Ok(vec![json!({"type":"text", "text":text})]);
894 }
895 let parts = value.and_then(Value::as_array).ok_or_else(|| {
896 OpenAiError::invalid("Message content must be a string or array", Some(param))
897 })?;
898 let mut out = Vec::new();
899 for (index, part) in parts.iter().enumerate() {
900 let part_param = format!("{param}[{index}]");
901 let object = part.as_object().ok_or_else(|| {
902 OpenAiError::invalid("Content parts must be objects", Some(&part_param))
903 })?;
904 match object.get("type").and_then(Value::as_str) {
905 Some("input_text" | "output_text" | "text") => out.push(json!({
906 "type":"text",
907 "text":required_string(object, "text", &format!("{part_param}.text"))?,
908 })),
909 Some("input_image") => {
910 let url = object
911 .get("image_url")
912 .or_else(|| object.get("url"))
913 .and_then(Value::as_str)
914 .ok_or_else(|| {
915 OpenAiError::invalid(
916 "Input image requires 'image_url'",
917 Some(format!("{part_param}.image_url")),
918 )
919 })?;
920 out.push(image_url_block(url, &format!("{part_param}.image_url"))?);
921 }
922 _ => return Err(OpenAiError::unsupported(format!("{part_param}.type"))),
923 }
924 }
925 Ok(out)
926}
927
928fn image_url_block(url: &str, param: &str) -> Result<Value, OpenAiError> {
929 if let Some(data) = url.strip_prefix("data:") {
930 let (media_type, encoded) = data.split_once(";base64,").ok_or_else(|| {
931 OpenAiError::invalid("Image data URL must use base64 encoding", Some(param))
932 })?;
933 if encoded.len() > MAX_IMAGE_DATA_BYTES * 4 / 3 + 4 {
934 return Err(OpenAiError::invalid("Image data is too large", Some(param)));
935 }
936 let media_type = media_type.split(';').next().unwrap_or(media_type);
937 if !matches!(
938 media_type,
939 "image/png" | "image/jpeg" | "image/gif" | "image/webp"
940 ) {
941 return Err(OpenAiError::invalid(
942 format!("Unsupported image media type '{media_type}'"),
943 Some(param),
944 ));
945 }
946 Ok(json!({
947 "type":"image",
948 "source":{"type":"base64", "media_type":media_type, "data":encoded},
949 }))
950 } else if url.starts_with("https://") || url.starts_with("http://") {
951 Ok(json!({"type":"image", "source":{"type":"url", "url":url}}))
952 } else {
953 Err(OpenAiError::invalid(
954 "Image URL must use http, https, or a base64 data URL",
955 Some(param),
956 ))
957 }
958}
959
960fn validate_cursor(
961 provider: &str,
962 session_id: Option<&str>,
963 stream: bool,
964 messages: &[Message],
965 tools: &[Value],
966) -> Result<(), OpenAiError> {
967 if provider != "cursor" {
968 return Ok(());
969 }
970 if messages
971 .iter()
972 .any(|message| contains_url_image(&message.content))
973 {
974 return Err(OpenAiError::unsupported(
975 "input image URL for provider 'cursor'",
976 ));
977 }
978 if tools.is_empty() {
979 return Ok(());
980 }
981 if !stream {
982 return Err(OpenAiError::invalid(
983 "Cursor tools require 'stream' to be true",
984 Some("stream"),
985 ));
986 }
987 if session_id.is_none_or(str::is_empty) {
988 return Err(OpenAiError::invalid(
989 "Cursor tools require a stable session header",
990 Some("tools"),
991 ));
992 }
993 for (index, tool) in tools.iter().enumerate() {
994 let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
995 if !matches!(name, "Read" | "Write" | "Bash") {
996 return Err(OpenAiError::invalid(
997 format!("Cursor cannot bridge tool '{name}'"),
998 Some(format!("tools[{index}].function.name")),
999 ));
1000 }
1001 }
1002 Ok(())
1003}
1004
1005fn contains_url_image(content: &Value) -> bool {
1006 content.as_array().is_some_and(|blocks| {
1007 blocks.iter().any(|block| {
1008 block.get("type").and_then(Value::as_str) == Some("image")
1009 && block.pointer("/source/type").and_then(Value::as_str) == Some("url")
1010 })
1011 })
1012}
1013
1014fn push_tool_result(messages: &mut Vec<Message>, result: Value) {
1015 if let Some(last) = messages.last_mut()
1016 && last.role == "user"
1017 && last.content.as_array().is_some_and(|blocks| {
1018 blocks
1019 .iter()
1020 .all(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
1021 })
1022 {
1023 last.content
1024 .as_array_mut()
1025 .expect("checked tool result array")
1026 .push(result);
1027 } else {
1028 messages.push(Message {
1029 role: "user".to_string(),
1030 content: Value::Array(vec![result]),
1031 });
1032 }
1033}
1034
1035fn required_string(
1036 object: &Map<String, Value>,
1037 key: &str,
1038 param: &str,
1039) -> Result<String, OpenAiError> {
1040 object
1041 .get(key)
1042 .and_then(Value::as_str)
1043 .filter(|value| !value.is_empty())
1044 .map(str::to_string)
1045 .ok_or_else(|| {
1046 OpenAiError::invalid(format!("'{param}' must be a non-empty string"), Some(param))
1047 })
1048}
1049
1050fn reject_nested_fields(
1051 object: &Map<String, Value>,
1052 allowed: &[&str],
1053 parent: &str,
1054) -> Result<(), OpenAiError> {
1055 for (key, value) in object {
1056 if !allowed.contains(&key.as_str()) && !value.is_null() {
1057 return Err(OpenAiError::unsupported(format!("{parent}.{key}")));
1058 }
1059 }
1060 Ok(())
1061}
1062
1063fn content_text(value: Option<&Value>, param: &str) -> Result<String, OpenAiError> {
1064 match value {
1065 Some(Value::String(text)) if !text.is_empty() => Ok(text.clone()),
1066 Some(Value::Array(parts)) => {
1067 let mut out = Vec::new();
1068 for (index, part) in parts.iter().enumerate() {
1069 let object = part.as_object().ok_or_else(|| {
1070 OpenAiError::invalid(
1071 "Text content parts must be objects",
1072 Some(format!("{param}[{index}]")),
1073 )
1074 })?;
1075 if !matches!(
1076 object.get("type").and_then(Value::as_str),
1077 Some("text" | "input_text" | "output_text")
1078 ) {
1079 return Err(OpenAiError::unsupported(format!("{param}[{index}].type")));
1080 }
1081 out.push(required_string(
1082 object,
1083 "text",
1084 &format!("{param}[{index}].text"),
1085 )?);
1086 }
1087 if out.is_empty() {
1088 Err(OpenAiError::invalid(
1089 "Content must not be empty",
1090 Some(param),
1091 ))
1092 } else {
1093 Ok(out.join(""))
1094 }
1095 }
1096 _ => Err(OpenAiError::invalid(
1097 "Content must be non-empty text",
1098 Some(param),
1099 )),
1100 }
1101}
1102
1103fn blocks_text(blocks: &[Value]) -> String {
1104 blocks
1105 .iter()
1106 .filter_map(|block| block.get("text").and_then(Value::as_str))
1107 .collect::<Vec<_>>()
1108 .join("")
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114
1115 #[test]
1116 fn chat_maps_tools_and_results() {
1117 let parsed = parse_request(
1118 OpenAiSurface::ChatCompletions,
1119 json!({
1120 "model":"kimi-k2.6",
1121 "messages":[
1122 {"role":"user","content":"look up x"},
1123 {"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},
1124 {"role":"tool","tool_call_id":"call_1","content":"answer"}
1125 ],
1126 "tools":[{"type":"function","function":{"name":"lookup","description":"lookup","parameters":{"type":"object"}}}],
1127 "tool_choice":{"type":"function","function":{"name":"lookup"}}
1128 }),
1129 "kimi",
1130 Some("session"),
1131 )
1132 .unwrap();
1133 assert_eq!(parsed.messages.extra["tools"][0]["name"], "lookup");
1134 assert_eq!(parsed.messages.messages[1].content[0]["type"], "tool_use");
1135 assert_eq!(
1136 parsed.messages.messages[2].content[0]["type"],
1137 "tool_result"
1138 );
1139 assert_eq!(parsed.messages.extra["tool_choice"]["name"], "lookup");
1140 }
1141
1142 #[test]
1143 fn parallel_tool_calls_sets_anthropic_tool_choice_policy() {
1144 let cases = [
1145 (None, "auto"),
1146 (Some(json!("auto")), "auto"),
1147 (Some(json!("none")), "none"),
1148 (Some(json!("required")), "any"),
1149 (
1150 Some(json!({"type":"function","function":{"name":"lookup"}})),
1151 "tool",
1152 ),
1153 ];
1154 for parallel in [false, true] {
1155 for (choice, expected_type) in &cases {
1156 let mut body = json!({
1157 "model":"kimi-k2.6",
1158 "messages":[{"role":"user","content":"look up x"}],
1159 "tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}}],
1160 "parallel_tool_calls":parallel,
1161 });
1162 if let Some(choice) = choice {
1163 body["tool_choice"] = choice.clone();
1164 }
1165 let parsed = parse_request(
1166 OpenAiSurface::ChatCompletions,
1167 body,
1168 "kimi",
1169 Some("session"),
1170 )
1171 .unwrap();
1172 let translated = &parsed.messages.extra["tool_choice"];
1173 assert_eq!(translated["type"], *expected_type);
1174 assert_eq!(translated["disable_parallel_tool_use"], !parallel);
1175 }
1176 }
1177 }
1178
1179 #[test]
1180 fn responses_parallel_tool_calls_supports_named_choice() {
1181 let parsed = parse_request(
1182 OpenAiSurface::Responses,
1183 json!({
1184 "model":"grok-4.5",
1185 "input":"look up x",
1186 "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
1187 "tool_choice":{"type":"function","name":"lookup"},
1188 "parallel_tool_calls":false,
1189 }),
1190 "grok",
1191 None,
1192 )
1193 .unwrap();
1194 assert_eq!(parsed.messages.extra["tool_choice"]["type"], "tool");
1195 assert_eq!(
1196 parsed.messages.extra["tool_choice"]["disable_parallel_tool_use"],
1197 true
1198 );
1199 }
1200
1201 #[test]
1202 fn parallel_tool_calls_must_be_boolean() {
1203 let error = parse_request(
1204 OpenAiSurface::Responses,
1205 json!({"model":"grok-4.5","input":"hello","parallel_tool_calls":"false"}),
1206 "grok",
1207 None,
1208 )
1209 .unwrap_err();
1210 assert_eq!(error.param.as_deref(), Some("parallel_tool_calls"));
1211 assert!(error.code.is_none());
1212 }
1213
1214 #[test]
1215 fn responses_maps_function_items() {
1216 let parsed = parse_request(
1217 OpenAiSurface::Responses,
1218 json!({
1219 "model":"grok-4.5",
1220 "instructions":"be concise",
1221 "input":[
1222 {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},
1223 {"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},
1224 {"type":"function_call_output","call_id":"call_1","output":"done"}
1225 ],
1226 "reasoning":{"effort":"high"}
1227 }),
1228 "grok",
1229 None,
1230 )
1231 .unwrap();
1232 assert_eq!(parsed.messages.extra["system"][0]["text"], "be concise");
1233 assert_eq!(parsed.messages.messages[1].content[0]["type"], "tool_use");
1234 assert_eq!(parsed.messages.extra["output_config"]["effort"], "high");
1235 }
1236
1237 #[test]
1238 fn rejects_unsupported_fields_and_cursor_tools_without_session() {
1239 let error = parse_request(
1240 OpenAiSurface::ChatCompletions,
1241 json!({"model":"kimi-k2.6","messages":[{"role":"user","content":"x"}],"temperature":0.5}),
1242 "kimi",
1243 None,
1244 )
1245 .unwrap_err();
1246 assert_eq!(error.param.as_deref(), Some("temperature"));
1247 assert_eq!(error.code.as_deref(), Some("unsupported_parameter"));
1248
1249 let error = parse_request(
1250 OpenAiSurface::ChatCompletions,
1251 json!({
1252 "model":"cursor:gpt-5.5",
1253 "stream":true,
1254 "messages":[{"role":"user","content":"x"}],
1255 "tools":[{"type":"function","function":{"name":"Read","parameters":{"type":"object"}}}]
1256 }),
1257 "cursor",
1258 None,
1259 )
1260 .unwrap_err();
1261 assert_eq!(error.param.as_deref(), Some("tools"));
1262 }
1263
1264 #[test]
1265 fn rejects_duplicate_results_store_and_buffered_cursor_tools() {
1266 let duplicate = parse_request(
1267 OpenAiSurface::ChatCompletions,
1268 json!({
1269 "model":"kimi-k2.6",
1270 "messages":[
1271 {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},
1272 {"role":"tool","tool_call_id":"call_1","content":"first"},
1273 {"role":"tool","tool_call_id":"call_1","content":"second"}
1274 ]
1275 }),
1276 "kimi",
1277 Some("session"),
1278 )
1279 .unwrap_err();
1280 assert!(duplicate.message.contains("unknown call"));
1281
1282 let store = parse_request(
1283 OpenAiSurface::Responses,
1284 json!({"model":"grok-4.5","input":"hello","store":true}),
1285 "grok",
1286 None,
1287 )
1288 .unwrap_err();
1289 assert_eq!(store.param.as_deref(), Some("store"));
1290
1291 let cursor = parse_request(
1292 OpenAiSurface::ChatCompletions,
1293 json!({
1294 "model":"cursor:gpt-5.5",
1295 "messages":[{"role":"user","content":"x"}],
1296 "tools":[{"type":"function","function":{"name":"Read","parameters":{"type":"object"}}}]
1297 }),
1298 "cursor",
1299 Some("session"),
1300 )
1301 .unwrap_err();
1302 assert_eq!(cursor.param.as_deref(), Some("stream"));
1303 }
1304
1305 #[test]
1306 fn normalizes_model_and_preserves_requested_value() {
1307 let parsed = parse_request(
1308 OpenAiSurface::Responses,
1309 json!({"model":"grok-4.5[1m]","input":"hello"}),
1310 "grok",
1311 None,
1312 )
1313 .unwrap();
1314 assert_eq!(parsed.requested_model, "grok-4.5[1m]");
1315 assert_eq!(parsed.normalized_model, "grok-4.5");
1316 assert_eq!(parsed.messages.model.as_deref(), Some("grok-4.5"));
1317 }
1318}