mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 19:29:50 +08:00
435 lines
14 KiB
Rust
435 lines
14 KiB
Rust
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<String>,
|
|
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<bool>,
|
|
) -> 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<bool>,
|
|
) -> 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");
|
|
}
|
|
}
|