feat: add model directive management

This commit is contained in:
fawney19
2026-05-03 14:48:25 +08:00
parent fe27fb17fb
commit c4ea042eb4
53 changed files with 2655 additions and 182 deletions

View File

@@ -11,10 +11,12 @@ use aether_ai_formats::protocol::registry::{convert_request, FormatContext};
use aether_ai_formats::provider_compat::proxy::rules::apply_local_body_rules;
use serde_json::Value;
use crate::request::model_directives::apply_model_directive_overrides_from_request;
use super::{
apply_openai_responses_compact_special_body_edits,
codex::apply_codex_openai_responses_special_body_edits,
normalize::build_local_openai_chat_request_body,
normalize::build_local_openai_chat_request_body_with_model_directives,
};
#[allow(clippy::too_many_arguments)]
@@ -28,6 +30,33 @@ pub fn build_standard_request_body(
upstream_is_stream: bool,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
) -> Option<Value> {
build_standard_request_body_with_model_directives(
body_json,
client_api_format,
mapped_model,
provider_type,
provider_api_format,
request_path,
upstream_is_stream,
body_rules,
user_api_key_id,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body_with_model_directives(
body_json: &Value,
client_api_format: &str,
mapped_model: &str,
provider_type: &str,
provider_api_format: &str,
request_path: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
enable_model_directives: bool,
) -> Option<Value> {
let format_context = FormatContext::default()
.with_mapped_model(mapped_model)
@@ -41,6 +70,16 @@ pub fn build_standard_request_body(
)
.ok()?;
if enable_model_directives {
apply_model_directive_overrides_from_request(
&mut provider_request_body,
provider_api_format,
mapped_model,
body_json,
Some(request_path),
);
}
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
return None;
}
@@ -64,36 +103,64 @@ pub fn build_standard_request_body_from_canonical(
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
match aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => build_local_openai_chat_request_body(
canonical_request,
build_standard_request_body_from_canonical_with_model_directives(
canonical_request,
mapped_model,
provider_api_format,
upstream_is_stream,
false,
)
}
pub fn build_standard_request_body_from_canonical_with_model_directives(
canonical_request: &Value,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let mut provider_request_body =
match aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => build_local_openai_chat_request_body_with_model_directives(
canonical_request,
mapped_model,
upstream_is_stream,
enable_model_directives,
),
"openai:responses" => convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
upstream_is_stream,
false,
),
"openai:responses:compact" => convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
false,
true,
),
"claude:messages" => convert_openai_chat_request_to_claude_request(
canonical_request,
mapped_model,
upstream_is_stream,
),
"gemini:generate_content" => convert_openai_chat_request_to_gemini_request(
canonical_request,
mapped_model,
upstream_is_stream,
),
_ => None,
}?;
if enable_model_directives {
apply_model_directive_overrides_from_request(
&mut provider_request_body,
provider_api_format,
mapped_model,
upstream_is_stream,
),
"openai:responses" => convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
upstream_is_stream,
false,
),
"openai:responses:compact" => convert_openai_chat_request_to_openai_responses_request(
canonical_request,
mapped_model,
false,
true,
),
"claude:messages" => convert_openai_chat_request_to_claude_request(
canonical_request,
mapped_model,
upstream_is_stream,
),
"gemini:generate_content" => convert_openai_chat_request_to_gemini_request(
canonical_request,
mapped_model,
upstream_is_stream,
),
_ => None,
None,
);
}
Some(provider_request_body)
}
pub fn normalize_standard_request_to_openai_chat_request(
@@ -133,6 +200,7 @@ fn normalize_standard_request_to_openai_chat_request_cow<'a>(
mod tests {
use super::{
build_standard_request_body, build_standard_request_body_from_canonical,
build_standard_request_body_with_model_directives,
normalize_standard_request_to_openai_chat_request,
};
use serde_json::{json, Value};
@@ -425,6 +493,60 @@ mod tests {
}
}
#[test]
fn standard_request_body_applies_reasoning_effort_suffix_to_claude_target() {
let request = json!({
"model": "gpt-5.4-max",
"messages": [{"role": "user", "content": "Need high effort"}],
"reasoning_effort": "low"
});
let converted = build_standard_request_body_with_model_directives(
&request,
"openai:chat",
"claude-sonnet-4-5",
"anthropic",
"claude:messages",
"/v1/chat/completions",
false,
None,
None,
true,
)
.expect("openai chat should convert to claude chat");
assert_eq!(converted["model"], "claude-sonnet-4-5");
assert_eq!(converted["output_config"]["effort"], "max");
assert_eq!(converted["thinking"]["budget_tokens"], 8192);
}
#[test]
fn standard_request_body_applies_reasoning_effort_suffix_from_gemini_path() {
let request = json!({
"contents": [{
"role": "user",
"parts": [{"text": "Need high effort"}]
}]
});
let converted = build_standard_request_body_with_model_directives(
&request,
"gemini:generate_content",
"gpt-5.4",
"openai",
"openai:chat",
"/v1beta/models/gemini-2.5-pro-high:generateContent",
false,
None,
None,
true,
)
.expect("gemini should convert to openai chat");
assert_eq!(converted["model"], "gpt-5.4");
assert_eq!(converted["reasoning_effort"], "high");
}
#[test]
fn openai_chat_request_uses_typed_canonical_without_changing_target_payloads() {
let request = json!({

View File

@@ -13,8 +13,18 @@ pub use codex::{
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
};
pub use family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
pub use matrix::{build_standard_request_body, normalize_standard_request_to_openai_chat_request};
pub use normalize::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
build_local_openai_chat_request_body, build_local_openai_responses_request_body,
pub use matrix::{
build_standard_request_body, build_standard_request_body_from_canonical_with_model_directives,
build_standard_request_body_with_model_directives,
normalize_standard_request_to_openai_chat_request,
};
pub use normalize::{
build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives,
build_local_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives,
build_local_openai_responses_request_body,
build_local_openai_responses_request_body_with_model_directives,
};

View File

@@ -6,10 +6,26 @@ use aether_ai_formats::protocol::conversion::request::{
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
use serde_json::{json, Value};
use crate::request::model_directives::apply_model_directive_overrides_from_request;
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
build_local_openai_chat_request_body_with_model_directives(
body_json,
mapped_model,
upstream_is_stream,
false,
)
}
pub fn build_local_openai_chat_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
@@ -34,7 +50,14 @@ pub fn build_local_openai_chat_request_body(
}
}
}
Some(Value::Object(provider_request_body))
Some(with_model_directive_overrides(
Value::Object(provider_request_body),
"openai:chat",
mapped_model,
body_json,
None,
enable_model_directives,
))
}
pub fn build_cross_format_openai_chat_request_body(
@@ -42,35 +65,73 @@ pub fn build_cross_format_openai_chat_request_body(
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
build_cross_format_openai_chat_request_body_with_model_directives(
body_json,
mapped_model,
provider_api_format,
upstream_is_stream,
false,
)
}
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
match conversion_kind {
let provider_request_body = match conversion_kind {
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
body_json,
mapped_model,
upstream_is_stream,
),
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
body_json,
mapped_model,
upstream_is_stream,
),
)?,
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
body_json,
mapped_model,
upstream_is_stream,
false,
)
)?
}
_ => None,
}
_ => return None,
};
Some(with_model_directive_overrides(
provider_request_body,
provider_api_format,
mapped_model,
body_json,
None,
enable_model_directives,
))
}
pub fn build_local_openai_responses_request_body(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
) -> Option<Value> {
build_local_openai_responses_request_body_with_model_directives(
body_json,
mapped_model,
require_streaming,
false,
)
}
pub fn build_local_openai_responses_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
enable_model_directives: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
@@ -82,7 +143,14 @@ pub fn build_local_openai_responses_request_body(
if require_streaming {
provider_request_body.insert("stream".to_string(), Value::Bool(true));
}
Some(Value::Object(provider_request_body))
Some(with_model_directive_overrides(
Value::Object(provider_request_body),
"openai:responses",
mapped_model,
body_json,
None,
enable_model_directives,
))
}
pub fn build_cross_format_openai_responses_request_body(
@@ -91,41 +159,93 @@ pub fn build_cross_format_openai_responses_request_body(
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
build_cross_format_openai_responses_request_body_with_model_directives(
body_json,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
false,
)
}
pub fn build_cross_format_openai_responses_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
match conversion_kind {
RequestConversionKind::ToOpenAIChat => build_local_openai_chat_request_body(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
let provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIChat => {
build_local_openai_chat_request_body_with_model_directives(
&chat_like_request,
mapped_model,
upstream_is_stream,
enable_model_directives,
)?
}
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
false,
)
)?
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&chat_like_request,
mapped_model,
upstream_is_stream,
),
)?,
};
Some(with_model_directive_overrides(
provider_request_body,
provider_api_format,
mapped_model,
body_json,
None,
enable_model_directives,
))
}
fn with_model_directive_overrides(
mut provider_request_body: Value,
provider_api_format: &str,
provider_model: &str,
request_body: &Value,
request_path: Option<&str>,
enable_model_directives: bool,
) -> Value {
if enable_model_directives {
apply_model_directive_overrides_from_request(
&mut provider_request_body,
provider_api_format,
provider_model,
request_body,
request_path,
);
}
provider_request_body
}
#[cfg(test)]
mod tests {
use super::build_local_openai_responses_request_body;
use super::{
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives,
build_local_openai_responses_request_body_with_model_directives,
};
use serde_json::{json, Value};
@@ -204,6 +324,87 @@ mod tests {
);
}
#[test]
fn local_openai_chat_request_body_applies_reasoning_effort_suffix() {
let body_json = json!({
"model": "gpt-5.4-xhigh",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "low"
});
let provider_request_body = build_local_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
false,
true,
)
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
}
#[test]
fn local_openai_chat_request_body_leaves_model_directive_disabled_by_default() {
let body_json = json!({
"model": "gpt-5.4-xhigh",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "low"
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["reasoning_effort"], "low");
}
#[test]
fn local_openai_responses_request_body_applies_reasoning_effort_suffix() {
let body_json = json!({
"model": "gpt-5.4-max",
"input": "hello",
"reasoning": {"effort": "low", "summary": "auto"}
});
let provider_request_body =
build_local_openai_responses_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
false,
true,
)
.expect("openai responses body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
assert_eq!(provider_request_body["reasoning"]["effort"], "xhigh");
}
#[test]
fn cross_format_request_body_applies_reasoning_effort_suffix() {
let body_json = json!({
"model": "gpt-5.4-high",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "low"
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"claude-sonnet-4-5",
"claude:messages",
false,
true,
)
.expect("claude body should build");
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
assert_eq!(provider_request_body["output_config"]["effort"], "high");
assert_eq!(provider_request_body["thinking"]["budget_tokens"], 4096);
}
#[test]
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
) {