use serde_json::Value; use crate::formats::shared::model_directives::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OpenAiReasoningViolationKind { InvalidType, InvalidEnum, UnsupportedForModel, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenAiReasoningContractViolation { pub kind: OpenAiReasoningViolationKind, pub field: String, pub value: Option, pub reason: String, } pub fn validate_openai_reasoning_request( source_api_format: &str, provider_api_format: &str, provider_model: &str, body: &Value, ) -> Result<(), OpenAiReasoningContractViolation> { let source_model = body .get("model") .and_then(Value::as_str) .unwrap_or_default(); validate_openai_reasoning_request_with_source_model( source_api_format, provider_api_format, provider_model, source_model, body, ) } pub(crate) fn validate_openai_reasoning_request_with_source_model( source_api_format: &str, provider_api_format: &str, provider_model: &str, source_model: &str, body: &Value, ) -> Result<(), OpenAiReasoningContractViolation> { validate_openai_reasoning_request_with_model_profile( source_api_format, provider_api_format, provider_model, source_model, body, false, None, ) } pub(crate) fn validate_openai_reasoning_request_with_model_profile( source_api_format: &str, provider_api_format: &str, provider_model: &str, source_model: &str, body: &Value, use_model_card_reasoning_contract: bool, supports_reasoning_mode: Option, ) -> Result<(), OpenAiReasoningContractViolation> { let Some(object) = body.as_object() else { return Ok(()); }; let source_api_format = crate::normalize_api_format_alias(source_api_format); let reasoning = match source_api_format.as_str() { "openai:responses" | "openai:responses:compact" => match object.get("reasoning") { Some(Value::Object(reasoning)) => Some(reasoning), Some(Value::Null) => None, Some(value) => { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidType, field: "reasoning".to_string(), value: Some(value.to_string()), reason: "reasoning must be an object".to_string(), }); } None => None, }, "openai:chat" => None, _ => return Ok(()), }; let provider_model = provider_model.trim(); let provider_model = if provider_model.is_empty() { source_model } else { provider_model }; let effort = match source_api_format.as_str() { "openai:chat" => object.get("reasoning_effort"), "openai:responses" | "openai:responses:compact" => { reasoning.and_then(|reasoning| reasoning.get("effort")) } _ => None, }; if let Some(value) = effort.filter(|value| !value.is_null()) { validate_reasoning_effort( value, source_api_format.as_str(), provider_api_format, provider_model, source_model, use_model_card_reasoning_contract, )?; } if let Some(mode) = reasoning .and_then(|reasoning| reasoning.get("mode")) .filter(|value| !value.is_null()) { validate_reasoning_mode(mode, provider_model, source_model, supports_reasoning_mode)?; } if let Some(context) = reasoning .and_then(|reasoning| reasoning.get("context")) .filter(|value| !value.is_null()) { validate_reasoning_context(context)?; } Ok(()) } fn validate_reasoning_effort( value: &Value, source_api_format: &str, provider_api_format: &str, provider_model: &str, source_model: &str, use_model_card_reasoning_contract: bool, ) -> Result<(), OpenAiReasoningContractViolation> { let field = if source_api_format == "openai:chat" { "reasoning_effort" } else { "reasoning.effort" }; let Some(raw) = value.as_str() else { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidType, field: field.to_string(), value: Some(value.to_string()), reason: "reasoning effort must be a string".to_string(), }); }; if raw.trim().is_empty() { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidEnum, field: field.to_string(), value: Some(raw.to_string()), reason: "reasoning effort must not be empty".to_string(), }); } if raw.trim() == "ultra" { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidEnum, field: field.to_string(), value: Some(raw.to_string()), reason: "ultra is a Codex client preset, not an OpenAI wire effort".to_string(), }); } if use_model_card_reasoning_contract { return Ok(()); } let Some(effort) = ReasoningEffort::parse(raw) else { return Ok(()); }; if crate::reasoning_effort_supported_for_model( provider_api_format, provider_model, source_model, effort, ) { return Ok(()); } Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::UnsupportedForModel, field: field.to_string(), value: Some(raw.to_string()), reason: "provider model does not support the requested reasoning effort".to_string(), }) } fn validate_reasoning_mode( value: &Value, provider_model: &str, source_model: &str, supports_reasoning_mode: Option, ) -> Result<(), OpenAiReasoningContractViolation> { let Some(mode) = value.as_str() else { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidType, field: "reasoning.mode".to_string(), value: Some(value.to_string()), reason: "reasoning mode must be a string".to_string(), }); }; if mode.trim().is_empty() { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidEnum, field: "reasoning.mode".to_string(), value: Some(mode.to_string()), reason: "reasoning mode must not be empty".to_string(), }); } if !matches!(mode, "standard" | "pro") { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidEnum, field: "reasoning.mode".to_string(), value: Some(mode.to_string()), reason: "reasoning mode supports standard or pro".to_string(), }); } let supported = supports_reasoning_mode.unwrap_or_else(|| { crate::formats::shared::model_directives::openai_model_resolves_to_gpt_5_6( provider_model, source_model, ) }); if supported { return Ok(()); } Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::UnsupportedForModel, field: "reasoning.mode".to_string(), value: Some(mode.to_string()), reason: "provider model does not support reasoning mode".to_string(), }) } fn validate_reasoning_context(value: &Value) -> Result<(), OpenAiReasoningContractViolation> { let Some(context) = value.as_str() else { return Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidType, field: "reasoning.context".to_string(), value: Some(value.to_string()), reason: "reasoning context must be a string".to_string(), }); }; if matches!(context, "auto" | "current_turn" | "all_turns") { return Ok(()); } Err(OpenAiReasoningContractViolation { kind: OpenAiReasoningViolationKind::InvalidEnum, field: "reasoning.context".to_string(), value: Some(context.to_string()), reason: "reasoning context is not a supported wire value".to_string(), }) } #[cfg(test)] mod tests { use serde_json::json; use super::{validate_openai_reasoning_request, OpenAiReasoningViolationKind}; #[test] fn mapped_model_is_authoritative_for_openai_reasoning_effort() { let alias = json!({ "model": "deployment-alias", "reasoning": {"effort": "max"} }); validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &alias, ) .expect("GPT-5.6 should accept max"); let error = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.4", &alias, ) .expect_err("GPT-5.4 should reject max"); assert_eq!( error.kind, OpenAiReasoningViolationKind::UnsupportedForModel ); } #[test] fn gpt_5_6_rejects_known_unsupported_effort_and_preserves_custom_effort() { let unsupported = json!({ "model": "gpt-5.6-terra", "reasoning_effort": "minimal" }); let error = validate_openai_reasoning_request( "openai:chat", "openai:chat", "gpt-5.6-terra", &unsupported, ) .expect_err("known unsupported effort should be rejected"); assert_eq!( error.kind, OpenAiReasoningViolationKind::UnsupportedForModel ); validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-terra", &json!({ "model": "gpt-5.6-terra", "reasoning": {"effort": "future"} }), ) .expect("model-advertised custom effort should pass through"); let ultra = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-terra", &json!({"reasoning": {"effort": "ultra"}}), ) .expect_err("Codex local ultra preset should not enter the OpenAI wire contract"); assert_eq!(ultra.kind, OpenAiReasoningViolationKind::InvalidEnum); let empty = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-terra", &json!({"reasoning": {"effort": " "}}), ) .expect_err("empty reasoning effort should be rejected"); assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum); } #[test] fn reasoning_mode_is_responses_only_and_requires_gpt_5_6() { for mode in ["standard", "pro"] { validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &json!({"model": "deployment-alias", "reasoning": {"mode": mode}}), ) .expect("GPT-5.6 should accept reasoning mode"); } let unsupported = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.4", &json!({"reasoning": {"mode": "pro"}}), ) .expect_err("earlier GPT models should reject reasoning mode"); assert_eq!( unsupported.kind, OpenAiReasoningViolationKind::UnsupportedForModel ); let invalid = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &json!({"reasoning": {"mode": "fast"}}), ) .expect_err("unknown reasoning mode should be rejected"); assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum); let empty = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &json!({"reasoning": {"mode": ""}}), ) .expect_err("empty reasoning mode should be rejected"); assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum); validate_openai_reasoning_request( "openai:chat", "openai:chat", "gpt-5.4", &json!({"reasoning": {"mode": "pro"}}), ) .expect("Chat Completions does not define reasoning.mode"); } #[test] fn reasoning_context_validates_wire_values_without_model_gating() { for context in ["auto", "current_turn", "all_turns"] { validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.4", &json!({"reasoning": {"context": context}}), ) .expect("reasoning context should remain available to Codex Responses models"); } let invalid = validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &json!({"reasoning": {"context": "session"}}), ) .expect_err("unknown reasoning context should be rejected"); assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum); } #[test] fn nullable_reasoning_fields_are_treated_as_unconfigured() { for body in [ json!({"model": "gpt-5.6-sol", "reasoning": null}), json!({ "model": "gpt-5.6-sol", "reasoning": {"effort": null, "mode": null, "context": null} }), ] { validate_openai_reasoning_request( "openai:responses", "openai:responses", "gpt-5.6-sol", &body, ) .expect("nullable Responses reasoning fields should be omitted semantically"); } validate_openai_reasoning_request( "openai:chat", "openai:chat", "gpt-5.6-sol", &json!({"model": "gpt-5.6-sol", "reasoning_effort": null}), ) .expect("nullable Chat reasoning effort should be omitted semantically"); } }