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(¶m), 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(¶m),
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}