Skip to main content

claude_codex/openai_compat/
request.rs

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(&param)))?;
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            &param,
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"), &param, &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(&param)))?;
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"], &param)?;
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                    &param,
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                    &param,
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(&param)))?;
592        reject_nested_fields(object, &["id", "type", "function"], &param)?;
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(&param)))?;
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"], &param)?;
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}