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

@@ -61,7 +61,17 @@ pub use crate::provider_compat::surfaces::{
pub use crate::request::common::{
force_upstream_streaming_for_provider, parse_direct_request_body,
};
pub use crate::request::matrix::build_standard_request_body_from_canonical;
pub use crate::request::matrix::{
build_standard_request_body_from_canonical,
build_standard_request_body_from_canonical_with_model_directives,
};
pub use crate::request::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort,
};
pub use crate::request::openai::{
copy_request_number_field, copy_request_number_field_as,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
@@ -98,8 +108,14 @@ pub use crate::request::specialized::{
pub use crate::request::standard::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_openai_responses_compact_special_body_edits, 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, build_standard_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, build_standard_request_body,
build_standard_request_body_with_model_directives,
claude::{
resolve_stream_spec as resolve_claude_stream_spec,
resolve_sync_spec as resolve_claude_sync_spec,

View File

@@ -36,3 +36,10 @@ pub use protocol::matrix::{
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
pub use protocol::registry::{build_stream_transcoder, convert_request, convert_response};
pub use request::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort,
};

View File

@@ -2503,7 +2503,8 @@ pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Op
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" | "xhigh" => Some("xhigh"),
"xhigh" => Some("xhigh"),
"max" => Some("max"),
_ => None,
}
}

View File

@@ -12,9 +12,12 @@ use crate::{
CanonicalRequest,
},
protocol::context::FormatContext,
request::openai::{
map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_thinking_budget,
request::{
model_directives::claude_model_uses_adaptive_effort,
openai::{
map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_thinking_budget,
},
},
};
@@ -155,14 +158,18 @@ pub fn to_raw(
let budget_tokens = thinking
.budget_tokens
.or_else(|| openai_effort.and_then(map_openai_reasoning_effort_to_thinking_budget));
let uses_adaptive = claude_model_uses_adaptive_effort(mapped_model)
|| claude_model_uses_adaptive_effort(canonical.model.as_str());
if thinking.enabled || budget_tokens.is_some() {
output.insert(
"thinking".to_string(),
let thinking_config = if uses_adaptive {
json!({"type": "adaptive"})
} else {
json!({
"type": "enabled",
"budget_tokens": budget_tokens.unwrap_or(1024),
}),
);
})
};
output.insert("thinking".to_string(), thinking_config);
}
if let Some(output_effort) =
openai_effort.and_then(map_openai_reasoning_effort_to_claude_output)

View File

@@ -15,7 +15,13 @@ use crate::{
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
protocol::context::FormatContext,
request::openai::map_openai_reasoning_effort_to_gemini_budget,
request::{
model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
openai::{
map_openai_reasoning_effort_to_gemini_budget,
map_thinking_budget_to_openai_reasoning_effort,
},
},
};
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
@@ -203,7 +209,8 @@ fn canonical_to_gemini_request_body(
if let Some(system_instruction) = canonical_system_instruction(canonical) {
output.insert("systemInstruction".to_string(), system_instruction);
}
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical) {
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical, mapped_model)
{
output.insert("generationConfig".to_string(), generation_config);
}
if let Some(tools) = canonical_tools_to_gemini(canonical) {
@@ -370,7 +377,10 @@ fn canonical_media_to_gemini_part(
})
}
fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
fn canonical_generation_config_to_gemini(
canonical: &CanonicalRequest,
mapped_model: &str,
) -> Option<Value> {
let mut generation_config = Map::new();
if let Some(value) = canonical.generation.max_tokens {
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
@@ -406,14 +416,8 @@ fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option
.and_then(|value| value.get("thinking_config"))
.cloned()
.or_else(|| {
let budget = thinking.budget_tokens.or_else(|| {
canonical_openai_reasoning_effort(thinking)
.and_then(map_openai_reasoning_effort_to_gemini_budget)
})?;
Some(json!({
"includeThoughts": true,
"thinkingBudget": budget,
}))
let effort = canonical_openai_reasoning_effort(thinking);
gemini_thinking_config_from_reasoning(mapped_model, effort, thinking.budget_tokens)
})
}) {
generation_config.insert("thinkingConfig".to_string(), thinking_config);
@@ -421,6 +425,34 @@ fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
}
fn gemini_thinking_config_from_reasoning(
mapped_model: &str,
effort: Option<&str>,
budget_tokens: Option<u64>,
) -> Option<Value> {
if gemini_model_uses_thinking_level(mapped_model) {
let level = effort
.and_then(ReasoningEffort::parse)
.or_else(|| {
budget_tokens
.map(map_thinking_budget_to_openai_reasoning_effort)
.and_then(ReasoningEffort::parse)
})
.map(ReasoningEffort::as_gemini_level_value)?;
return Some(json!({
"includeThoughts": true,
"thinkingLevel": level,
}));
}
let budget =
budget_tokens.or_else(|| effort.and_then(map_openai_reasoning_effort_to_gemini_budget))?;
Some(json!({
"includeThoughts": true,
"thinkingBudget": budget,
}))
}
fn apply_response_format_to_gemini_generation_config(
generation_config: &mut Map<String, Value>,
response_format: &CanonicalResponseFormat,

View File

@@ -397,7 +397,7 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": if effort == "xhigh" { "high" } else { effort },
"effort": openai_responses_reasoning_effort(effort),
})
})
})
@@ -410,6 +410,16 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
})
}
fn openai_responses_reasoning_effort(effort: &str) -> &str {
match effort.trim().to_ascii_lowercase().as_str() {
"xhigh" | "max" => "xhigh",
"low" => "low",
"medium" => "medium",
"high" => "high",
_ => effort,
}
}
fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let mut text = Map::new();
if let Some(response_format) = &canonical.response_format {

View File

@@ -1 +1,4 @@
pub use crate::request::standard::matrix::build_standard_request_body_from_canonical;
pub use crate::request::standard::matrix::{
build_standard_request_body_from_canonical,
build_standard_request_body_from_canonical_with_model_directives,
};

View File

@@ -1,5 +1,6 @@
pub mod common;
pub mod matrix;
pub mod model_directives;
pub mod openai;
pub mod passthrough;
pub mod route;

View File

@@ -0,0 +1,434 @@
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelDirective {
pub base_model: String,
pub overrides: Vec<ModelOverride>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelOverride {
ReasoningEffort(ReasoningEffort),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Low,
Medium,
High,
XHigh,
Max,
}
impl ReasoningEffort {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"max" => Some(Self::Max),
_ => None,
}
}
pub fn as_openai_chat_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "xhigh",
}
}
pub fn as_openai_responses_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh | Self::Max => "xhigh",
}
}
pub fn as_claude_output_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
pub fn as_gemini_level_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High | Self::XHigh | Self::Max => "high",
}
}
pub fn thinking_budget_tokens(self) -> u64 {
match self {
Self::Low => 1280,
Self::Medium => 2048,
Self::High => 4096,
Self::XHigh | Self::Max => 8192,
}
}
}
pub fn parse_model_directive(model: &str) -> Option<ModelDirective> {
let model = model.trim();
let (base_model, suffix) = model.rsplit_once('-')?;
let base_model = base_model.trim();
if base_model.is_empty() {
return None;
}
let reasoning_effort = ReasoningEffort::parse(suffix)?;
Some(ModelDirective {
base_model: base_model.to_string(),
overrides: vec![ModelOverride::ReasoningEffort(reasoning_effort)],
})
}
pub fn model_directive_base_model(model: &str) -> Option<String> {
parse_model_directive(model).map(|directive| directive.base_model)
}
pub fn normalize_model_directive_model(model: &str) -> String {
parse_model_directive(model)
.map(|directive| directive.base_model)
.unwrap_or_else(|| model.trim().to_string())
}
pub fn apply_model_directive_overrides_from_request(
provider_request_body: &mut Value,
provider_api_format: &str,
provider_model: &str,
request_body: &Value,
request_path: Option<&str>,
) -> Option<ModelDirective> {
let source_model = request_body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| request_path.and_then(extract_gemini_model_from_path))?;
apply_model_directive_overrides_from_model(
provider_request_body,
provider_api_format,
provider_model,
&source_model,
)
}
pub fn apply_model_directive_overrides_from_model(
provider_request_body: &mut Value,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
) -> Option<ModelDirective> {
let directive = parse_model_directive(source_model)?;
for override_item in &directive.overrides {
match override_item {
ModelOverride::ReasoningEffort(effort) => {
apply_reasoning_effort_override(
provider_request_body,
provider_api_format,
provider_model,
*effort,
)?;
}
}
}
Some(directive)
}
pub fn apply_model_directive_mapping_patch(
provider_request_body: &mut Value,
patch: &Value,
) -> Option<()> {
deep_merge_json(provider_request_body, patch);
Some(())
}
fn deep_merge_json(target: &mut Value, patch: &Value) {
match (target, patch) {
(Value::Object(target_object), Value::Object(patch_object)) => {
for (key, patch_value) in patch_object {
match target_object.get_mut(key) {
Some(target_value) => deep_merge_json(target_value, patch_value),
None => {
target_object.insert(key.clone(), patch_value.clone());
}
}
}
}
(target, patch) => {
*target = patch.clone();
}
}
}
fn apply_reasoning_effort_override(
provider_request_body: &mut Value,
provider_api_format: &str,
provider_model: &str,
effort: ReasoningEffort,
) -> Option<()> {
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => set_object_string(
provider_request_body,
"reasoning_effort",
effort.as_openai_chat_value(),
),
"openai:responses" | "openai:responses:compact" => {
set_openai_responses_reasoning_effort(provider_request_body, effort)
}
"claude:messages" => {
set_claude_reasoning_effort(provider_request_body, effort, provider_model)
}
"gemini:generate_content" => {
set_gemini_reasoning_effort(provider_request_body, effort, provider_model)
}
_ => None,
}
}
fn set_object_string(body: &mut Value, key: &str, value: &str) -> Option<()> {
body.as_object_mut()?
.insert(key.to_string(), Value::String(value.to_string()));
Some(())
}
fn set_openai_responses_reasoning_effort(body: &mut Value, effort: ReasoningEffort) -> Option<()> {
let body_object = body.as_object_mut()?;
let reasoning = body_object
.entry("reasoning".to_string())
.or_insert_with(|| json!({}));
if !reasoning.is_object() {
*reasoning = json!({});
}
reasoning.as_object_mut()?.insert(
"effort".to_string(),
Value::String(effort.as_openai_responses_value().to_string()),
);
Some(())
}
fn set_claude_reasoning_effort(
body: &mut Value,
effort: ReasoningEffort,
provider_model: &str,
) -> Option<()> {
let body_object = body.as_object_mut()?;
let output_config = body_object
.entry("output_config".to_string())
.or_insert_with(|| json!({}));
if !output_config.is_object() {
*output_config = json!({});
}
output_config.as_object_mut()?.insert(
"effort".to_string(),
Value::String(effort.as_claude_output_value().to_string()),
);
let thinking = body_object
.entry("thinking".to_string())
.or_insert_with(|| json!({}));
if !thinking.is_object() {
*thinking = json!({});
}
let thinking = thinking.as_object_mut()?;
if claude_model_uses_adaptive_effort(provider_model) {
thinking.insert("type".to_string(), Value::String("adaptive".to_string()));
thinking.remove("budget_tokens");
} else {
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
thinking.insert(
"budget_tokens".to_string(),
Value::from(effort.thinking_budget_tokens()),
);
}
Some(())
}
fn set_gemini_reasoning_effort(
body: &mut Value,
effort: ReasoningEffort,
provider_model: &str,
) -> Option<()> {
let body_object = body.as_object_mut()?;
let generation_key = if body_object.contains_key("generation_config")
&& !body_object.contains_key("generationConfig")
{
"generation_config"
} else {
"generationConfig"
};
let generation_config = body_object
.entry(generation_key.to_string())
.or_insert_with(|| json!({}));
if !generation_config.is_object() {
*generation_config = json!({});
}
let generation_config = generation_config.as_object_mut()?;
let thinking_key = if generation_config.contains_key("thinking_config")
&& !generation_config.contains_key("thinkingConfig")
{
"thinking_config"
} else {
"thinkingConfig"
};
generation_config.insert(
thinking_key.to_string(),
gemini_reasoning_effort_config(effort, provider_model, thinking_key),
);
Some(())
}
fn gemini_reasoning_effort_config(
effort: ReasoningEffort,
provider_model: &str,
thinking_key: &str,
) -> Value {
if gemini_model_uses_thinking_level(provider_model) {
if thinking_key == "thinking_config" {
return json!({
"include_thoughts": true,
"thinking_level": effort.as_gemini_level_value(),
});
}
return json!({
"includeThoughts": true,
"thinkingLevel": effort.as_gemini_level_value(),
});
}
if thinking_key == "thinking_config" {
return json!({
"include_thoughts": true,
"thinking_budget": effort.thinking_budget_tokens(),
});
}
json!({
"includeThoughts": true,
"thinkingBudget": effort.thinking_budget_tokens(),
})
}
pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
let model = model.trim().to_ascii_lowercase().replace(['.', '_'], "-");
model.contains("mythos")
|| model.contains("opus-4-7")
|| model.contains("opus-4-6")
|| model.contains("sonnet-4-6")
}
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
.split('/')
.any(|part| part.starts_with("gemini-3"))
}
pub fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
let tail = &path[start..];
let end = tail.find(':').unwrap_or(tail.len());
let model = tail[..end].trim();
(!model.is_empty()).then(|| model.to_string())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
apply_model_directive_overrides_from_model, parse_model_directive, ModelDirective,
ModelOverride, ReasoningEffort,
};
#[test]
fn parses_supported_reasoning_effort_suffixes() {
assert_eq!(
parse_model_directive("gpt-5.4-xhigh"),
Some(ModelDirective {
base_model: "gpt-5.4".to_string(),
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::XHigh)],
})
);
assert_eq!(
parse_model_directive("gpt-5.4-MAX"),
Some(ModelDirective {
base_model: "gpt-5.4".to_string(),
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::Max)],
})
);
}
#[test]
fn ignores_unknown_or_incomplete_suffixes() {
assert_eq!(parse_model_directive("gpt-5.4-ultra"), None);
assert_eq!(parse_model_directive("gpt-5.4"), None);
assert_eq!(parse_model_directive("-high"), None);
assert_eq!(parse_model_directive("gpt-5.4-high-json"), None);
}
#[test]
fn applies_reasoning_effort_to_provider_body_shapes() {
let mut openai_chat = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
apply_model_directive_overrides_from_model(
&mut openai_chat,
"openai:chat",
"gpt-5-upstream",
"gpt-5.4-xhigh",
)
.expect("directive should apply");
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
let mut responses = json!({
"model": "gpt-5-upstream",
"reasoning": {"effort": "low", "summary": "auto"}
});
apply_model_directive_overrides_from_model(
&mut responses,
"openai:responses",
"gpt-5-upstream",
"gpt-5.4-max",
)
.expect("directive should apply");
assert_eq!(responses["reasoning"]["effort"], "xhigh");
assert_eq!(responses["reasoning"]["summary"], "auto");
let mut claude = json!({"model": "claude-sonnet-4-5"});
apply_model_directive_overrides_from_model(
&mut claude,
"claude:messages",
"claude-sonnet-4-5",
"gpt-5.4-high",
)
.expect("directive should apply");
assert_eq!(claude["thinking"]["budget_tokens"], 4096);
let mut gemini = json!({});
apply_model_directive_overrides_from_model(
&mut gemini,
"gemini:generate_content",
"gemini-2.5-pro",
"gpt-5.4-medium",
)
.expect("directive should apply");
assert_eq!(
gemini["generationConfig"]["thinkingConfig"]["thinkingBudget"],
2048
);
}
}

View File

@@ -1,5 +1,7 @@
use serde_json::{Map, Value};
use super::model_directives::ReasoningEffort;
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
Some(Value::String(value)) if !value.trim().is_empty() => {
@@ -55,23 +57,11 @@ pub fn copy_request_number_field_as(
}
pub fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" => Some("max"),
_ => None,
}
ReasoningEffort::parse(value).map(ReasoningEffort::as_claude_output_value)
}
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some(1280),
"medium" => Some(2048),
"high" => Some(4096),
"xhigh" => Some(8192),
_ => None,
}
ReasoningEffort::parse(value).map(ReasoningEffort::thinking_budget_tokens)
}
pub fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {

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(
) {