Skip to main content

claude_codex/providers/codex/chat_completions/
request.rs

1use http::StatusCode;
2use serde_json::{Map, Value, json};
3
4use crate::providers::codex::translate::{
5    model_allowlist::{
6        ALLOWED_MODELS, assert_allowed_model, resolve_model_request, uses_responses_lite,
7    },
8    request::{Effort, resolve_effort_override, to_codex_effort},
9};
10use crate::{config, registry::normalize_incoming_model};
11
12use super::ChatError;
13
14const SUPPORTED_FIELDS: &[&str] = &[
15    "model",
16    "messages",
17    "stream",
18    "stream_options",
19    "reasoning_effort",
20    "response_format",
21    "temperature",
22    "top_p",
23    "user",
24];
25
26#[derive(Debug, Clone)]
27pub struct TranslatedRequest {
28    pub upstream: Value,
29    pub requested_model: String,
30    pub model: String,
31    pub effort: Option<String>,
32    pub stream: bool,
33    pub include_usage: bool,
34    pub use_responses_lite: bool,
35}
36
37pub fn translate_request(body: Value) -> Result<TranslatedRequest, ChatError> {
38    translate_request_with_override(body, config::codex_effort().as_deref())
39}
40
41fn translate_request_with_override(
42    body: Value,
43    effort_override: Option<&str>,
44) -> Result<TranslatedRequest, ChatError> {
45    let object = body
46        .as_object()
47        .ok_or_else(|| ChatError::invalid("Request body must be a JSON object", None, None))?;
48    reject_unsupported_fields(object)?;
49
50    let requested_model = required_string(object, "model")?;
51    let normalized = normalize_incoming_model(&requested_model);
52    let resolved = resolve_model_request(&normalized);
53    assert_allowed_model(&resolved.model).map_err(|error| {
54        ChatError::invalid(
55            format!(
56                "Model '{requested_model}' resolves to unsupported model '{}'. Supported: {}",
57                error.model,
58                ALLOWED_MODELS.join(", ")
59            ),
60            Some("model"),
61            Some("model_not_supported"),
62        )
63    })?;
64    let use_responses_lite = uses_responses_lite(&resolved.model);
65
66    let messages = object
67        .get("messages")
68        .and_then(Value::as_array)
69        .ok_or_else(|| {
70            ChatError::invalid("Missing or invalid 'messages'", Some("messages"), None)
71        })?;
72    if messages.is_empty() {
73        return Err(ChatError::invalid(
74            "'messages' must contain at least one message",
75            Some("messages"),
76            None,
77        ));
78    }
79    let input = messages
80        .iter()
81        .enumerate()
82        .map(translate_message)
83        .collect::<Result<Vec<_>, _>>()?;
84
85    let stream = optional_bool(object, "stream")?.unwrap_or(false);
86    let include_usage = translate_stream_options(object.get("stream_options"))?;
87    if include_usage && !stream {
88        return Err(ChatError::invalid(
89            "'stream_options' is only supported when 'stream' is true",
90            Some("stream_options"),
91            None,
92        ));
93    }
94
95    let request_effort = match object.get("reasoning_effort") {
96        None | Some(Value::Null) => Some(Effort::Medium),
97        Some(Value::String(value)) => parse_effort(value)?,
98        Some(_) => {
99            return Err(ChatError::invalid(
100                "'reasoning_effort' must be a string",
101                Some("reasoning_effort"),
102                None,
103            ));
104        }
105    };
106    let effort = resolve_effort_override(request_effort, effort_override).map_err(|error| {
107        ChatError::new(
108            StatusCode::INTERNAL_SERVER_ERROR,
109            "api_error",
110            error.to_string(),
111            None,
112            None,
113        )
114    })?;
115
116    let mut text = Map::from_iter([("verbosity".to_string(), json!("low"))]);
117    if let Some(format) = translate_response_format(object.get("response_format"))? {
118        text.insert("format".to_string(), format);
119    }
120
121    let mut upstream = Map::from_iter([
122        ("model".to_string(), json!(&resolved.model)),
123        ("input".to_string(), Value::Array(input)),
124        ("store".to_string(), json!(false)),
125        ("stream".to_string(), json!(true)),
126        ("parallel_tool_calls".to_string(), json!(false)),
127        ("client_metadata".to_string(), json!({"lite":"true"})),
128        ("text".to_string(), Value::Object(text)),
129    ]);
130    if let Some(tier) = resolved.service_tier {
131        upstream.insert(
132            "service_tier".to_string(),
133            serde_json::to_value(tier).unwrap(),
134        );
135    }
136    let mut reasoning = Map::from_iter([("context".to_string(), json!("all_turns"))]);
137    if let Some(effort) = effort.as_ref().filter(|effort| **effort != Effort::None) {
138        reasoning.insert("effort".to_string(), json!(effort));
139    }
140    upstream.insert("reasoning".to_string(), Value::Object(reasoning));
141
142    for param in ["temperature", "top_p"] {
143        if let Some(value) = object.get(param).filter(|value| !value.is_null()) {
144            if use_responses_lite {
145                return Err(ChatError::unsupported(param));
146            }
147            validate_sampling_value(param, value)?;
148            upstream.insert(param.to_string(), value.clone());
149        }
150    }
151    if let Some(user) = object.get("user").filter(|value| !value.is_null()) {
152        let user = user
153            .as_str()
154            .filter(|value| !value.is_empty() && value.len() <= 64)
155            .ok_or_else(|| {
156                ChatError::invalid(
157                    "'user' must be a non-empty string of at most 64 bytes",
158                    Some("user"),
159                    None,
160                )
161            })?;
162        upstream.insert("safety_identifier".to_string(), json!(user));
163    }
164
165    Ok(TranslatedRequest {
166        upstream: Value::Object(upstream),
167        requested_model,
168        model: resolved.model,
169        effort: effort
170            .map(|effort| effort.to_string())
171            .filter(|effort| effort != "none"),
172        stream,
173        include_usage,
174        use_responses_lite,
175    })
176}
177
178fn reject_unsupported_fields(object: &Map<String, Value>) -> Result<(), ChatError> {
179    for key in object.keys() {
180        if !SUPPORTED_FIELDS.contains(&key.as_str()) {
181            return Err(ChatError::unsupported(key));
182        }
183    }
184    Ok(())
185}
186
187fn required_string(object: &Map<String, Value>, key: &'static str) -> Result<String, ChatError> {
188    object
189        .get(key)
190        .and_then(Value::as_str)
191        .filter(|value| !value.is_empty())
192        .map(str::to_string)
193        .ok_or_else(|| ChatError::invalid(format!("Missing or invalid '{key}'"), Some(key), None))
194}
195
196fn optional_bool(
197    object: &Map<String, Value>,
198    key: &'static str,
199) -> Result<Option<bool>, ChatError> {
200    match object.get(key) {
201        None | Some(Value::Null) => Ok(None),
202        Some(Value::Bool(value)) => Ok(Some(*value)),
203        Some(_) => Err(ChatError::invalid(
204            format!("'{key}' must be a boolean"),
205            Some(key),
206            None,
207        )),
208    }
209}
210
211fn translate_message((index, message): (usize, &Value)) -> Result<Value, ChatError> {
212    let param = format!("messages[{index}]");
213    let object = message
214        .as_object()
215        .ok_or_else(|| ChatError::invalid("Each message must be an object", Some(&param), None))?;
216    for key in object.keys() {
217        if !matches!(key.as_str(), "role" | "content" | "name") {
218            return Err(ChatError::unsupported(format!("{param}.{key}")));
219        }
220    }
221    let role = object.get("role").and_then(Value::as_str).ok_or_else(|| {
222        ChatError::invalid(
223            "Each message requires a role",
224            Some(&format!("{param}.role")),
225            None,
226        )
227    })?;
228    let role = match role {
229        "system" | "developer" => "developer",
230        "user" => "user",
231        "assistant" => "assistant",
232        _ => {
233            return Err(ChatError::invalid(
234                format!("Unsupported message role: {role}"),
235                Some(&format!("{param}.role")),
236                Some("unsupported_value"),
237            ));
238        }
239    };
240    let parts = translate_content(object.get("content"), index)?;
241    Ok(json!({"type":"message", "role":role, "content":parts}))
242}
243
244fn translate_content(content: Option<&Value>, index: usize) -> Result<Vec<Value>, ChatError> {
245    let param = format!("messages[{index}].content");
246    let parts = match content {
247        Some(Value::String(text)) if !text.is_empty() => {
248            vec![json!({"type":"input_text", "text":text})]
249        }
250        Some(Value::Array(parts)) if !parts.is_empty() => parts
251            .iter()
252            .enumerate()
253            .map(|(part_index, part)| {
254                let part_param = format!("{param}[{part_index}]");
255                let object = part.as_object().ok_or_else(|| {
256                    ChatError::invalid(
257                        "Message content parts must be objects",
258                        Some(&part_param),
259                        None,
260                    )
261                })?;
262                if object.get("type").and_then(Value::as_str) != Some("text") {
263                    return Err(ChatError::invalid(
264                        "Only text message content is supported",
265                        Some(&format!("{part_param}.type")),
266                        Some("unsupported_value"),
267                    ));
268                }
269                let text = object
270                    .get("text")
271                    .and_then(Value::as_str)
272                    .filter(|text| !text.is_empty())
273                    .ok_or_else(|| {
274                        ChatError::invalid(
275                            "Text content must not be empty",
276                            Some(&format!("{part_param}.text")),
277                            None,
278                        )
279                    })?;
280                Ok(json!({"type":"input_text", "text":text}))
281            })
282            .collect::<Result<Vec<_>, _>>()?,
283        _ => {
284            return Err(ChatError::invalid(
285                "Message content must contain text",
286                Some(&param),
287                None,
288            ));
289        }
290    };
291    Ok(parts)
292}
293
294fn parse_effort(value: &str) -> Result<Option<Effort>, ChatError> {
295    if value == "none" {
296        return Ok(Some(Effort::None));
297    }
298    to_codex_effort(Some(value)).map(Some).ok_or_else(|| {
299        ChatError::invalid(
300            format!(
301                "Invalid reasoning effort '{value}'. Supported: none, low, medium, high, xhigh, max"
302            ),
303            Some("reasoning_effort"),
304            Some("unsupported_value"),
305        )
306    })
307}
308
309fn translate_response_format(value: Option<&Value>) -> Result<Option<Value>, ChatError> {
310    let Some(value) = value.filter(|value| !value.is_null()) else {
311        return Ok(None);
312    };
313    let object = value.as_object().ok_or_else(|| {
314        ChatError::invalid(
315            "'response_format' must be an object",
316            Some("response_format"),
317            None,
318        )
319    })?;
320    match object.get("type").and_then(Value::as_str) {
321        Some("text") => Ok(None),
322        Some("json_object") => Ok(Some(json!({"type":"json_object"}))),
323        Some("json_schema") => {
324            let format = object
325                .get("json_schema")
326                .and_then(Value::as_object)
327                .ok_or_else(|| {
328                    ChatError::invalid(
329                        "'response_format.json_schema' must be an object",
330                        Some("response_format.json_schema"),
331                        None,
332                    )
333                })?;
334            let name = format
335                .get("name")
336                .and_then(Value::as_str)
337                .filter(|name| !name.is_empty())
338                .ok_or_else(|| {
339                    ChatError::invalid(
340                        "JSON Schema output requires a name",
341                        Some("response_format.json_schema.name"),
342                        None,
343                    )
344                })?;
345            let schema = format
346                .get("schema")
347                .filter(|schema| schema.is_object())
348                .ok_or_else(|| {
349                    ChatError::invalid(
350                        "JSON Schema output requires an object schema",
351                        Some("response_format.json_schema.schema"),
352                        None,
353                    )
354                })?;
355            let strict = match format.get("strict") {
356                None | Some(Value::Null) => None,
357                Some(Value::Bool(value)) => Some(*value),
358                Some(_) => {
359                    return Err(ChatError::invalid(
360                        "JSON Schema strict must be a boolean",
361                        Some("response_format.json_schema.strict"),
362                        None,
363                    ));
364                }
365            };
366            let mut translated = Map::from_iter([
367                ("type".to_string(), json!("json_schema")),
368                ("name".to_string(), json!(name)),
369                ("schema".to_string(), schema.clone()),
370            ]);
371            if let Some(strict) = strict {
372                translated.insert("strict".to_string(), json!(strict));
373            }
374            Ok(Some(Value::Object(translated)))
375        }
376        Some(kind) => Err(ChatError::invalid(
377            format!("Unsupported response format: {kind}"),
378            Some("response_format.type"),
379            Some("unsupported_value"),
380        )),
381        None => Err(ChatError::invalid(
382            "'response_format.type' is required",
383            Some("response_format.type"),
384            None,
385        )),
386    }
387}
388
389fn translate_stream_options(value: Option<&Value>) -> Result<bool, ChatError> {
390    let Some(value) = value.filter(|value| !value.is_null()) else {
391        return Ok(false);
392    };
393    let object = value.as_object().ok_or_else(|| {
394        ChatError::invalid(
395            "'stream_options' must be an object",
396            Some("stream_options"),
397            None,
398        )
399    })?;
400    for key in object.keys() {
401        if key != "include_usage" {
402            return Err(ChatError::unsupported(format!("stream_options.{key}")));
403        }
404    }
405    match object.get("include_usage") {
406        None | Some(Value::Null) => Ok(false),
407        Some(Value::Bool(value)) => Ok(*value),
408        Some(_) => Err(ChatError::invalid(
409            "'stream_options.include_usage' must be a boolean",
410            Some("stream_options.include_usage"),
411            None,
412        )),
413    }
414}
415
416fn validate_sampling_value(param: &'static str, value: &Value) -> Result<(), ChatError> {
417    let number = value
418        .as_f64()
419        .filter(|value| value.is_finite())
420        .ok_or_else(|| {
421            ChatError::invalid(format!("'{param}' must be a number"), Some(param), None)
422        })?;
423    let valid = match param {
424        "temperature" => (0.0..=2.0).contains(&number),
425        _ => (0.0..=1.0).contains(&number),
426    };
427    if valid {
428        Ok(())
429    } else {
430        Err(ChatError::invalid(
431            format!("'{param}' is out of range"),
432            Some(param),
433            Some("invalid_value"),
434        ))
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn base() -> Value {
443        json!({"model":"gpt-5.6-sol","messages":[{"role":"system","content":"rules"},{"role":"user","content":[{"type":"text","text":"hello"}]}]})
444    }
445
446    #[test]
447    fn translates_messages_and_responses_lite_fields() {
448        let translated = translate_request(base()).unwrap();
449        assert_eq!(translated.upstream["input"][0]["role"], "developer");
450        assert_eq!(
451            translated.upstream["input"][1]["content"][0]["text"],
452            "hello"
453        );
454        assert_eq!(translated.upstream["store"], false);
455        assert_eq!(translated.upstream["stream"], true);
456        assert_eq!(translated.upstream["reasoning"]["effort"], "medium");
457        assert_eq!(translated.upstream["reasoning"]["context"], "all_turns");
458    }
459
460    #[test]
461    fn none_effort_retains_context() {
462        let mut body = base();
463        body["reasoning_effort"] = json!("none");
464        let translated = translate_request(body).unwrap();
465        assert!(translated.upstream["reasoning"].get("effort").is_none());
466        assert_eq!(translated.upstream["reasoning"]["context"], "all_turns");
467    }
468
469    #[test]
470    fn translates_strict_json_schema() {
471        let mut body = base();
472        body["response_format"] = json!({"type":"json_schema","json_schema":{"name":"answer","strict":true,"schema":{"type":"object"}}});
473        let translated = translate_request(body).unwrap();
474        assert_eq!(translated.upstream["text"]["format"]["name"], "answer");
475        assert_eq!(translated.upstream["text"]["format"]["strict"], true);
476    }
477
478    #[test]
479    fn rejects_empty_content_and_unsupported_controls() {
480        let mut empty = base();
481        empty["messages"][0]["content"] = json!("");
482        assert_eq!(
483            translate_request(empty).unwrap_err().param.as_deref(),
484            Some("messages[0].content")
485        );
486        let mut tokens = base();
487        tokens["max_tokens"] = json!(100);
488        let error = translate_request(tokens).unwrap_err();
489        assert_eq!(error.code.as_deref(), Some("unsupported_parameter"));
490        assert_eq!(error.param.as_deref(), Some("max_tokens"));
491    }
492
493    #[test]
494    fn requires_model_and_nonempty_messages() {
495        assert_eq!(
496            translate_request(json!({"messages":[{"role":"user","content":"hello"}]}))
497                .unwrap_err()
498                .param
499                .as_deref(),
500            Some("model")
501        );
502        assert_eq!(
503            translate_request(json!({"model":"gpt-5.6-sol","messages":[]}))
504                .unwrap_err()
505                .param
506                .as_deref(),
507            Some("messages")
508        );
509    }
510
511    #[test]
512    fn accepts_all_efforts_and_forced_override_wins() {
513        for effort in ["none", "low", "medium", "high", "xhigh", "max"] {
514            let mut body = base();
515            body["reasoning_effort"] = json!(effort);
516            assert!(translate_request_with_override(body, None).is_ok());
517        }
518        let mut body = base();
519        body["reasoning_effort"] = json!("low");
520        let translated = translate_request_with_override(body, Some("high")).unwrap();
521        assert_eq!(translated.upstream["reasoning"]["effort"], "high");
522        assert_eq!(translated.effort.as_deref(), Some("high"));
523    }
524
525    #[test]
526    fn validates_stream_options_and_sampling_controls() {
527        let mut options = base();
528        options["stream_options"] = json!({"include_usage":true});
529        assert_eq!(
530            translate_request(options).unwrap_err().param.as_deref(),
531            Some("stream_options")
532        );
533
534        let mut lite = base();
535        lite["temperature"] = json!(0.2);
536        assert_eq!(
537            translate_request(lite).unwrap_err().code.as_deref(),
538            Some("unsupported_parameter")
539        );
540
541        let mut full = base();
542        full["model"] = json!("gpt-5.4");
543        full["temperature"] = json!(0.2);
544        full["top_p"] = json!(0.9);
545        let translated = translate_request(full).unwrap();
546        assert_eq!(translated.upstream["temperature"], 0.2);
547        assert_eq!(translated.upstream["top_p"], 0.9);
548    }
549}