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

@@ -3,6 +3,7 @@ use aether_ai_serving::{
};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use async_trait::async_trait;
use std::collections::BTreeSet;
use crate::ai_serving::planner::candidate_resolution::SkippedLocalExecutionCandidate;
use crate::ai_serving::{GatewayAuthApiKeySnapshot, PlannerAppState};
@@ -25,6 +26,8 @@ struct GatewayLocalCandidatePreselectionPort<'a> {
auth_snapshot: &'a GatewayAuthApiKeySnapshot,
use_api_format_alias_match: bool,
key_mode: LocalCandidatePreselectionKeyMode,
candidate_api_formats: Vec<String>,
model_directive_enabled_api_formats: BTreeSet<String>,
}
#[async_trait]
@@ -34,13 +37,7 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
type Error = GatewayError;
fn candidate_api_formats(&self) -> Vec<String> {
crate::ai_serving::request_candidate_api_formats(
self.client_api_format,
self.require_streaming,
)
.into_iter()
.map(str::to_string)
.collect()
self.candidate_api_formats.clone()
}
fn candidate_api_format_matches_client(&self, candidate_api_format: &str) -> bool {
@@ -84,28 +81,36 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
fn candidate_allowed(
&self,
candidate: &Self::Candidate,
_candidate_api_format: &str,
candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
let enable_model_directives = self.model_directive_enabled_api_formats.contains(
&crate::ai_serving::normalize_api_format_alias(candidate_api_format),
);
matches_client_format
|| auth_snapshot_allows_cross_format_candidate(
self.auth_snapshot,
self.requested_model,
candidate,
enable_model_directives,
)
}
fn skipped_candidate_allowed(
&self,
skipped_candidate: &Self::Skipped,
_candidate_api_format: &str,
candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
let enable_model_directives = self.model_directive_enabled_api_formats.contains(
&crate::ai_serving::normalize_api_format_alias(candidate_api_format),
);
matches_client_format
|| auth_snapshot_allows_cross_format_candidate(
self.auth_snapshot,
self.requested_model,
&skipped_candidate.candidate,
enable_model_directives,
)
}
@@ -135,6 +140,24 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
>,
GatewayError,
> {
let candidate_api_formats =
crate::ai_serving::request_candidate_api_formats(client_api_format, require_streaming)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let mut model_directive_enabled_api_formats = BTreeSet::new();
for api_format in &candidate_api_formats {
if crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state.app(),
api_format,
Some(requested_model),
)
.await
{
model_directive_enabled_api_formats
.insert(crate::ai_serving::normalize_api_format_alias(api_format));
}
}
let port = GatewayLocalCandidatePreselectionPort {
state,
client_api_format,
@@ -144,6 +167,8 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
auth_snapshot,
use_api_format_alias_match,
key_mode,
candidate_api_formats,
model_directive_enabled_api_formats,
};
run_ai_candidate_preselection(&port).await
@@ -190,6 +215,7 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
auth_snapshot: &GatewayAuthApiKeySnapshot,
requested_model: &str,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
enable_model_directives: bool,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
@@ -206,9 +232,16 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let model_allowed = allowed_models
.iter()
.any(|value| value == requested_model || value == &candidate.global_model_name);
let requested_base_model = enable_model_directives
.then(|| crate::ai_serving::model_directive_base_model(requested_model))
.flatten();
let model_allowed = allowed_models.iter().any(|value| {
value == requested_model
|| value == &candidate.global_model_name
|| requested_base_model
.as_ref()
.is_some_and(|base_model| value == base_model)
});
if !model_allowed {
return false;
}

View File

@@ -63,8 +63,15 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
spec,
)
.await?;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
spec.api_format,
Some(&input.requested_model),
)
.await;
let Some(base_provider_request_body) =
let Some(mut base_provider_request_body) =
super::super::request::build_same_format_provider_request_body(
body_json,
&prepared.mapped_model,
@@ -73,6 +80,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
prepared.upstream_is_stream,
prepared.kiro_auth.as_ref(),
prepared.is_claude_code,
enable_model_directives,
)
else {
mark_skipped_local_same_format_provider_candidate_with_extra_data(
@@ -97,6 +105,19 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
.await;
return None;
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
spec.api_format,
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut base_provider_request_body,
&mapping,
);
}
let antigravity_auth = if prepared.is_antigravity {
match classify_local_antigravity_request_support(

View File

@@ -14,15 +14,19 @@ pub(crate) fn build_same_format_provider_request_body(
upstream_is_stream: bool,
kiro_auth: Option<&crate::ai_serving::transport::kiro::KiroRequestAuth>,
is_claude_code: bool,
enable_model_directives: bool,
) -> Option<Value> {
build_same_format_provider_request_body_impl(SameFormatProviderRequestBodyInput {
body_json,
mapped_model,
provider_api_format: spec.api_format,
source_model: body_json.get("model").and_then(Value::as_str),
family: same_format_provider_family(spec.family),
body_rules,
upstream_is_stream,
kiro_auth_config: kiro_auth.map(|auth| &auth.auth_config),
is_claude_code,
enable_model_directives,
})
}

View File

@@ -168,8 +168,15 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
transport.provider.provider_type.as_str(),
provider_api_format,
);
let provider_request_body =
match crate::ai_serving::planner::standard::build_standard_request_body(
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await;
let mut provider_request_body =
match crate::ai_serving::planner::standard::build_standard_request_body_with_model_directives(
body_json,
spec_metadata.api_format,
&prepared_candidate.mapped_model,
@@ -183,6 +190,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
enable_model_directives,
) {
Some(body) => body,
None => {
@@ -204,6 +212,19 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
return None;
}
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut provider_request_body,
&mapping,
);
}
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_cross_format_payload_parts(

View File

@@ -45,8 +45,8 @@ pub(crate) use crate::ai_serving::{
SyncCliResponseConversionKind,
};
pub(crate) use crate::ai_serving::{
build_standard_request_body, convert_openai_chat_request_to_claude_request,
convert_openai_chat_request_to_gemini_request,
build_standard_request_body, build_standard_request_body_with_model_directives,
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
};

View File

@@ -4,8 +4,8 @@ use crate::ai_serving::transport::apply_standard_provider_request_body_rules;
use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits,
build_cross_format_openai_chat_request_body as surface_build_cross_format_openai_chat_request_body,
build_local_openai_chat_request_body as surface_build_local_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives as surface_build_cross_format_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives as surface_build_local_openai_chat_request_body,
GatewayProviderTransportSnapshot,
};
@@ -14,9 +14,14 @@ pub(crate) fn build_local_openai_chat_request_body(
mapped_model: &str,
upstream_is_stream: bool,
body_rules: Option<&Value>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body =
surface_build_local_openai_chat_request_body(body_json, mapped_model, upstream_is_stream)?;
let provider_request_body = surface_build_local_openai_chat_request_body(
body_json,
mapped_model,
upstream_is_stream,
enable_model_directives,
)?;
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)
}
@@ -35,12 +40,14 @@ pub(crate) fn build_cross_format_openai_chat_request_body(
upstream_is_stream: bool,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body = surface_build_cross_format_openai_chat_request_body(
body_json,
mapped_model,
provider_api_format,
upstream_is_stream,
enable_model_directives,
)?;
let mut provider_request_body =
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;

View File

@@ -4,8 +4,8 @@ use crate::ai_serving::transport::apply_standard_provider_request_body_rules;
use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits,
build_cross_format_openai_responses_request_body as surface_build_cross_format_openai_responses_request_body,
build_local_openai_responses_request_body as surface_build_local_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives as surface_build_cross_format_openai_responses_request_body,
build_local_openai_responses_request_body_with_model_directives as surface_build_local_openai_responses_request_body,
GatewayProviderTransportSnapshot,
};
@@ -17,11 +17,13 @@ pub(crate) fn build_local_openai_responses_request_body(
provider_api_format: &str,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body = surface_build_local_openai_responses_request_body(
body_json,
mapped_model,
require_streaming,
enable_model_directives,
)?;
let mut provider_request_body =
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;
@@ -48,6 +50,7 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
provider_type: &str,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body = surface_build_cross_format_openai_responses_request_body(
body_json,
@@ -55,6 +58,7 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
client_api_format,
provider_api_format,
upstream_is_stream,
enable_model_directives,
)?;
let mut provider_request_body =
apply_standard_provider_request_body_rules(provider_request_body, body_rules, body_json)?;

View File

@@ -90,6 +90,7 @@ fn builds_openai_chat_cross_format_request_body_from_openai_responses_source() {
"openai",
None,
None,
false,
)
.expect("openai responses to openai chat body should build");
@@ -123,6 +124,7 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
"openai:responses",
None,
Some("key-123"),
false,
)
.expect("local openai responses body should build");
@@ -160,12 +162,41 @@ fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests(
"openai:responses:compact",
None,
None,
false,
)
.expect("local openai compact body should build");
assert!(provider_request_body.get("store").is_none());
}
#[test]
fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
let body_json = json!({
"model": "gpt-5.4-max",
"input": "hello",
"reasoning": {"effort": "low", "summary": "auto"}
});
let body_rules = json!([
{"action":"set","path":"metadata.override_seen","value":true}
]);
let provider_request_body = build_local_openai_responses_request_body(
&body_json,
"gpt-5.4",
false,
"openai",
"openai:responses",
Some(&body_rules),
None,
true,
)
.expect("local openai responses body should build");
assert_eq!(provider_request_body["reasoning"]["effort"], "xhigh");
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
assert_eq!(provider_request_body["metadata"]["override_seen"], true);
}
#[test]
fn local_openai_responses_upstream_url_preserves_codex_base_path() {
let request = Request::builder()
@@ -205,6 +236,7 @@ fn strips_metadata_for_codex_openai_responses_requests() {
"codex",
None,
None,
false,
)
.expect("claude cli to codex request should build");
@@ -237,6 +269,7 @@ fn applies_codex_defaults_unless_body_rules_handle_the_field() {
"codex",
Some(&body_rules),
None,
false,
)
.expect("claude cli to codex request should build");
@@ -264,6 +297,7 @@ fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
"codex",
None,
Some("key-123"),
false,
)
.expect("claude cli to codex request should build");
@@ -291,6 +325,7 @@ fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
false,
None,
Some("key-123"),
false,
)
.expect("openai chat to codex request should build");

View File

@@ -72,6 +72,13 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
let candidate = &eligible.candidate;
let provider_api_format = eligible.provider_api_format.as_str();
let transport = &eligible.transport;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await;
if provider_api_format == "openai:chat" {
if let Some(skip_reason) = local_openai_chat_transport_unsupported_reason(transport) {
@@ -122,6 +129,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
&prepared_candidate.mapped_model,
upstream_is_stream,
transport.endpoint.body_rules.as_ref(),
enable_model_directives,
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
@@ -334,7 +342,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
}
};
let Some(provider_request_body) = build_cross_format_openai_chat_request_body(
let Some(mut provider_request_body) = build_cross_format_openai_chat_request_body(
body_json,
&prepared_candidate.mapped_model,
transport.provider.provider_type.as_str(),
@@ -346,6 +354,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
enable_model_directives,
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
@@ -364,6 +373,19 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await;
return None;
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format.as_str(),
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut provider_request_body,
&mapping,
);
}
if let Some(kiro_auth) = kiro_auth.as_ref() {
return build_kiro_openai_chat_cross_format_payload_parts(

View File

@@ -215,6 +215,13 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
let auth_header = prepared_candidate.auth_header;
let auth_value = prepared_candidate.auth_value;
let mapped_model = prepared_candidate.mapped_model;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await;
let needs_bidirectional_conversion = !same_format && conversion_kind.is_some();
let upstream_is_stream = spec_metadata.require_streaming
@@ -223,7 +230,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
transport.provider.provider_type.as_str(),
provider_api_format,
);
let Some(base_provider_request_body) = (if needs_bidirectional_conversion {
let Some(mut base_provider_request_body) = (if needs_bidirectional_conversion {
build_cross_format_openai_responses_request_body(
body_json,
&mapped_model,
@@ -237,6 +244,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
enable_model_directives,
)
} else {
build_local_openai_responses_request_body(
@@ -251,6 +259,7 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
enable_model_directives,
)
}) else {
mark_skipped_local_openai_responses_candidate_with_extra_data(
@@ -270,6 +279,19 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
.await;
return None;
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut base_provider_request_body,
&mapping,
);
}
let antigravity_auth = if is_antigravity {
match classify_local_antigravity_request_support(
transport,

View File

@@ -11,12 +11,15 @@ impl<'a> PlannerAppState<'a> {
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Value>,
) -> Option<Value> {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled(self.app()).await;
crate::request_candidate_runtime::resolve_request_candidate_required_capabilities(
self.app(),
user_id,
api_key_id,
requested_model,
explicit_required_capabilities,
enable_model_directives,
)
.await
}

View File

@@ -20,6 +20,13 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
self.app(),
api_format,
Some(global_model_name),
)
.await;
crate::scheduler::candidate::list_selectable_candidates(
self.app().data.as_ref(),
self.app(),
@@ -29,6 +36,7 @@ impl<'a> PlannerAppState<'a> {
required_capabilities,
auth_snapshot,
now_unix_secs,
enable_model_directives,
)
.await
}
@@ -52,6 +60,13 @@ impl<'a> PlannerAppState<'a> {
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
let wait_deadline = Instant::now() + wait_timeout;
let mut attempt_now_unix_secs = now_unix_secs;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
self.app(),
api_format,
Some(global_model_name),
)
.await;
loop {
let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons(
@@ -63,6 +78,7 @@ impl<'a> PlannerAppState<'a> {
required_capabilities,
auth_snapshot,
attempt_now_unix_secs,
enable_model_directives,
)
.await?;

View File

@@ -3,19 +3,28 @@ pub(crate) use aether_ai_formats::api::{
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
api_format_alias_matches, apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_headers,
apply_codex_openai_responses_special_headers, apply_model_directive_mapping_patch,
apply_model_directive_overrides_from_model, apply_model_directive_overrides_from_request,
apply_openai_responses_compact_special_body_edits, build_core_error_body_for_client_format,
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
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_generated_tool_call_id, build_kiro_final_message_sse_events,
build_kiro_initial_sse_events, build_kiro_stream_error_sse_events,
build_local_openai_chat_request_body, build_local_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,
build_local_openai_responses_request_body_with_model_directives,
build_local_success_background_report, build_local_success_conversion_background_report,
build_openai_image_provider_request_body, build_openai_responses_response,
build_standard_request_body, build_standard_request_body_from_canonical,
calculate_kiro_context_input_tokens, canonicalize_tool_arguments,
convert_claude_chat_response_to_openai_chat, convert_claude_response_to_openai_responses,
convert_gemini_chat_response_to_openai_chat, convert_gemini_response_to_openai_responses,
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
build_standard_request_body_from_canonical_with_model_directives,
build_standard_request_body_with_model_directives, calculate_kiro_context_input_tokens,
canonicalize_tool_arguments, convert_claude_chat_response_to_openai_chat,
convert_claude_response_to_openai_responses, convert_gemini_chat_response_to_openai_chat,
convert_gemini_response_to_openai_responses, convert_openai_chat_request_to_claude_request,
convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request,
convert_openai_chat_response_to_claude_chat, convert_openai_chat_response_to_gemini_chat,
convert_openai_chat_response_to_openai_responses,
@@ -39,8 +48,8 @@ pub(crate) use aether_ai_formats::api::{
maybe_build_provider_private_stream_normalizer, maybe_build_standard_cross_format_sync_product,
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
maybe_build_standard_same_format_sync_body_from_normalized_payload,
maybe_build_standard_sync_finalize_product_from_normalized_payload, normalize_api_format_alias,
normalize_claude_request_to_openai_chat_request,
maybe_build_standard_sync_finalize_product_from_normalized_payload, model_directive_base_model,
normalize_api_format_alias, normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request, normalize_openai_image_request,
normalize_openai_responses_request_to_openai_chat_request,
normalize_provider_private_report_context, normalize_provider_private_response_value,

View File

@@ -68,6 +68,16 @@ pub(crate) async fn request_model_local_rejection(
if contains_string(allowed_models, &requested_model) {
return Ok(None);
}
if model_directive_base_model_is_allowed_for_request(
state,
decision,
&requested_model,
allowed_models,
)
.await
{
return Ok(None);
}
if request_model_resolves_to_allowed_model(state, decision, &requested_model, allowed_models)
.await?
{
@@ -79,6 +89,40 @@ pub(crate) async fn request_model_local_rejection(
}))
}
async fn model_directive_base_model_is_allowed_for_request(
state: &AppState,
decision: &GatewayControlDecision,
requested_model: &str,
allowed_models: &[String],
) -> bool {
let Some(base_model) = crate::ai_serving::model_directive_base_model(requested_model) else {
return false;
};
if !contains_string(allowed_models, &base_model) {
return false;
}
let Some(client_api_format) = decision
.auth_endpoint_signature
.as_deref()
.map(crate::ai_serving::normalize_api_format_alias)
.filter(|value| !value.trim().is_empty())
else {
return false;
};
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
if crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
&api_format,
Some(requested_model),
)
.await
{
return true;
}
}
false
}
async fn request_model_resolves_to_allowed_model(
state: &AppState,
decision: &GatewayControlDecision,
@@ -95,24 +139,33 @@ async fn request_model_resolves_to_allowed_model(
};
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
&api_format,
Some(requested_model),
)
.await;
let rows = state
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
.await?;
let matching_rows = rows
.into_iter()
.filter(|row| {
aether_scheduler_core::row_supports_requested_model(
aether_scheduler_core::row_supports_requested_model_with_model_directives(
row,
requested_model,
&api_format,
enable_model_directives,
)
})
.collect::<Vec<_>>();
let Some(resolved_global_model) =
aether_scheduler_core::resolve_requested_global_model_name(
aether_scheduler_core::resolve_requested_global_model_name_with_model_directives(
&matching_rows,
requested_model,
&api_format,
enable_model_directives,
)
else {
continue;

View File

@@ -2,10 +2,10 @@ use aether_data::DataLayerError;
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use aether_scheduler_core::{
auth_constraints_allow_api_format, collect_global_model_names_for_required_capability,
enumerate_minimal_candidate_selection, normalize_api_format,
resolve_requested_global_model_name, row_supports_requested_model,
EnumerateMinimalCandidateSelectionInput, SchedulerAuthConstraints,
SchedulerMinimalCandidateSelectionCandidate,
enumerate_minimal_candidate_selection_with_model_directives, normalize_api_format,
resolve_requested_global_model_name_with_model_directives,
row_supports_requested_model_with_model_directives, EnumerateMinimalCandidateSelectionInput,
SchedulerAuthConstraints, SchedulerMinimalCandidateSelectionCandidate,
};
use async_trait::async_trait;
use std::collections::BTreeSet;
@@ -30,20 +30,33 @@ pub(crate) async fn read_requested_model_rows(
state: &(impl MinimalCandidateSelectionRowSource + Sync),
api_format: &str,
requested_model_name: &str,
enable_model_directives: bool,
) -> Result<Option<(String, Vec<StoredMinimalCandidateSelectionRow>)>, DataLayerError> {
let rows = state
.read_minimal_candidate_selection_rows_for_api_format(api_format)
.await?;
let rows = rows
.into_iter()
.filter(|row| row_supports_requested_model(row, requested_model_name, api_format))
.filter(|row| {
row_supports_requested_model_with_model_directives(
row,
requested_model_name,
api_format,
enable_model_directives,
)
})
.collect::<Vec<_>>();
if rows.is_empty() {
return Ok(None);
}
let Some(resolved_global_model_name) =
resolve_requested_global_model_name(&rows, requested_model_name, api_format)
resolve_requested_global_model_name_with_model_directives(
&rows,
requested_model_name,
api_format,
enable_model_directives,
)
else {
return Ok(None);
};
@@ -63,6 +76,7 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
require_streaming: bool,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
required_capabilities: Option<&serde_json::Value>,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
let normalized_api_format = normalize_api_format(api_format);
if normalized_api_format.is_empty() {
@@ -76,13 +90,19 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
return Ok(Vec::new());
}
let Some((resolved_global_model_name, rows)) =
read_requested_model_rows(state, &normalized_api_format, requested_model_name).await?
let Some((resolved_global_model_name, rows)) = read_requested_model_rows(
state,
&normalized_api_format,
requested_model_name,
enable_model_directives,
)
.await?
else {
return Ok(Vec::new());
};
let auth_constraints = auth_snapshot.map(auth_snapshot_constraints);
enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
enumerate_minimal_candidate_selection_with_model_directives(
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: &normalized_api_format,
requested_model_name,
@@ -90,7 +110,9 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
require_streaming,
required_capabilities,
auth_constraints: auth_constraints.as_ref(),
})
},
enable_model_directives,
)
}
pub(crate) async fn read_global_model_names_for_required_capability(

View File

@@ -84,9 +84,10 @@ impl<'a> AdminAppState<'a> {
}
}
let enabled_config_key = admin_system_modules::admin_module_enabled_config_key(module);
let _ = self
.upsert_system_config_json_value(
&format!("module.{}.enabled", module.name),
&enabled_config_key,
&json!(payload.enabled),
Some(&format!("模块 [{}] 启用状态", module.display_name)),
)

View File

@@ -1,5 +1,6 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::{module_available_from_env, system_config_bool};
use crate::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY;
use crate::GatewayError;
use aether_admin::system as admin_system_kernel;
use serde_json::json;
@@ -66,6 +67,18 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
admin_menu_group: Some("system"),
admin_menu_order: 58,
},
AdminModuleDefinition {
name: "model_directives",
display_name: "模型后缀参数",
description: "允许通过模型名后缀覆盖推理参数",
category: "integration",
env_key: "MODEL_DIRECTIVES_AVAILABLE",
default_available: true,
admin_route: Some("/admin/model-directives"),
admin_menu_icon: Some("SlidersHorizontal"),
admin_menu_group: None,
admin_menu_order: 59,
},
AdminModuleDefinition {
name: "gemini_files",
display_name: "文件缓存",
@@ -118,6 +131,14 @@ pub(crate) fn admin_module_name_from_enabled_path(request_path: &str) -> Option<
admin_system_kernel::admin_module_name_from_enabled_path(request_path)
}
pub(crate) fn admin_module_enabled_config_key(module: &AdminModuleDefinition) -> String {
if module.name == "model_directives" {
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
} else {
format!("module.{}.enabled", module.name)
}
}
pub(crate) fn oauth_module_config_is_valid(
providers: &[aether_data::repository::auth_modules::StoredOAuthProviderModuleConfig],
) -> bool {
@@ -220,7 +241,7 @@ pub(crate) async fn build_admin_module_status_payload(
let available = module_available_from_env(module.env_key, module.default_available);
let enabled = if available {
let enabled = state
.read_system_config_json_value(&format!("module.{}.enabled", module.name))
.read_system_config_json_value(&admin_module_enabled_config_key(module))
.await?;
system_config_bool(enabled.as_ref(), false)
} else {

View File

@@ -57,6 +57,7 @@ mod request_candidate_runtime;
mod router;
mod scheduler;
mod state;
mod system_features;
mod tunnel;
mod usage;
mod video_tasks;

View File

@@ -72,6 +72,7 @@ pub(crate) async fn resolve_request_candidate_required_capabilities(
api_key_id: &str,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Value>,
enable_model_directives: bool,
) -> Option<Value> {
let mut merged = serde_json::Map::new();
@@ -81,7 +82,11 @@ pub(crate) async fn resolve_request_candidate_required_capabilities(
{
Ok(settings) => merge_capability_object(
&mut merged,
select_requested_model_capabilities(settings.as_ref(), requested_model),
select_requested_model_capabilities(
settings.as_ref(),
requested_model,
enable_model_directives,
),
),
Err(error) => {
warn!(
@@ -133,12 +138,26 @@ fn merge_capability_object(target: &mut serde_json::Map<String, Value>, source:
fn select_requested_model_capabilities<'a>(
settings: Option<&'a Value>,
requested_model: Option<&str>,
enable_model_directives: bool,
) -> Option<&'a Value> {
let requested_model = requested_model
.map(str::trim)
.filter(|value| !value.is_empty())?;
let settings = settings?.as_object()?;
find_model_capabilities(settings, requested_model).or_else(|| {
enable_model_directives
.then(|| crate::ai_serving::model_directive_base_model(requested_model))
.flatten()
.as_deref()
.and_then(|base_model| find_model_capabilities(settings, base_model))
})
}
fn find_model_capabilities<'a>(
settings: &'a serde_json::Map<String, Value>,
requested_model: &str,
) -> Option<&'a Value> {
settings.get(requested_model).or_else(|| {
settings.iter().find_map(|(model_name, capabilities)| {
model_name
@@ -961,6 +980,7 @@ mod tests {
"api-key-1",
Some("gpt-5"),
Some(&explicit_required_capabilities),
false,
)
.await
.expect("required capabilities should resolve");

View File

@@ -14,6 +14,7 @@ pub(super) async fn enumerate_scheduler_candidates(
require_streaming: bool,
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
enumerate_minimal_candidate_selection_with_required_capabilities(
selection_row_source,
@@ -22,6 +23,7 @@ pub(super) async fn enumerate_scheduler_candidates(
require_streaming,
auth_snapshot,
required_capabilities,
enable_model_directives,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))

View File

@@ -57,6 +57,7 @@ pub(crate) async fn list_selectable_candidates(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
collect_selectable_candidates(
selection_row_source,
@@ -67,6 +68,7 @@ pub(crate) async fn list_selectable_candidates(
required_capabilities,
auth_snapshot,
now_unix_secs,
enable_model_directives,
)
.await
}
@@ -87,6 +89,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -103,6 +106,7 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons(
required_capabilities,
auth_snapshot,
now_unix_secs,
enable_model_directives,
)
.await
}
@@ -181,6 +185,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r
required_capabilities.as_ref(),
auth_snapshot,
now_unix_secs,
false,
)
.await?;
all_attempts_blocked_by_auth_limit &=

View File

@@ -42,6 +42,7 @@ pub(super) async fn select_minimal_candidate(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<Option<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let affinity_cache_key =
build_scheduler_affinity_cache_key(auth_snapshot, api_format, global_model_name);
@@ -54,6 +55,7 @@ pub(super) async fn select_minimal_candidate(
required_capabilities,
auth_snapshot,
now_unix_secs,
enable_model_directives,
)
.await?
.into_iter()
@@ -73,6 +75,7 @@ pub(super) async fn collect_selectable_candidates(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
Ok(collect_selectable_candidates_with_skip_reasons(
selection_row_source,
@@ -83,6 +86,7 @@ pub(super) async fn collect_selectable_candidates(
required_capabilities,
auth_snapshot,
now_unix_secs,
enable_model_directives,
)
.await?
.0)
@@ -97,6 +101,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
required_capabilities: Option<&serde_json::Value>,
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -114,6 +119,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons(
require_streaming,
required_capabilities,
auth_snapshot,
enable_model_directives,
)
.await?;
let runtime_snapshot =

View File

@@ -46,6 +46,7 @@ async fn select_candidate(
None,
auth_snapshot,
now_unix_secs,
false,
)
.await
}
@@ -92,7 +93,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() {
let auth_snapshot = sample_auth_snapshot("affinity-key-1");
let (_resolved_global_model_name, rows) =
read_requested_model_rows(&state, "openai:chat", "gpt-4.1")
read_requested_model_rows(&state, "openai:chat", "gpt-4.1", false)
.await
.expect("selection rows should read")
.expect("selection rows should match requested model");

View File

@@ -142,6 +142,7 @@ async fn enumerate_minimal_candidate_selection_resolves_provider_model_alias() {
false,
None,
None,
false,
)
.await
.expect("selection should succeed");
@@ -191,6 +192,7 @@ async fn enumerate_minimal_candidate_selection_keeps_only_resolved_global_model_
false,
None,
None,
false,
)
.await
.expect("selection should succeed");
@@ -231,6 +233,7 @@ async fn enumerate_minimal_candidate_selection_allows_resolved_global_model_in_a
false,
Some(&auth_snapshot),
None,
false,
)
.await
.expect("selection should succeed");
@@ -238,3 +241,43 @@ async fn enumerate_minimal_candidate_selection_allows_resolved_global_model_in_a
assert_eq!(selection.len(), 1);
assert_eq!(selection[0].global_model_name, "gpt-5");
}
#[tokio::test]
async fn enumerate_minimal_candidate_selection_gates_model_directive_fallback() {
let mut row = sample_row();
row.global_model_name = "gpt-5.4".to_string();
row.model_provider_model_name = "gpt-5.4-upstream".to_string();
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
row,
]));
let quotas = Arc::new(InMemoryProviderQuotaRepository::seed(vec![]));
let state = GatewayDataState::with_candidate_selection_and_quota_for_tests(candidates, quotas);
let disabled = enumerate_minimal_candidate_selection_with_required_capabilities(
&state,
"openai:chat",
"gpt-5.4-high",
false,
None,
None,
false,
)
.await
.expect("selection should succeed");
assert!(disabled.is_empty());
let enabled = enumerate_minimal_candidate_selection_with_required_capabilities(
&state,
"openai:chat",
"gpt-5.4-high",
false,
None,
None,
true,
)
.await
.expect("selection should succeed");
assert_eq!(enabled.len(), 1);
assert_eq!(enabled[0].global_model_name, "gpt-5.4");
}

View File

@@ -48,6 +48,7 @@ async fn select_candidate(
None,
auth_snapshot,
now_unix_secs,
false,
)
.await
}
@@ -70,6 +71,7 @@ async fn collect_selectable_candidates(
None,
auth_snapshot,
now_unix_secs,
false,
)
.await
}
@@ -98,6 +100,7 @@ async fn collect_selectable_candidates_with_skip_reasons(
None,
auth_snapshot,
now_unix_secs,
false,
)
.await
}
@@ -404,6 +407,7 @@ async fn scheduler_selection_prefers_required_capability_matches_before_priority
Some(&required_capabilities),
None,
100,
false,
)
.await
.expect("selection should succeed")

View File

@@ -0,0 +1,335 @@
use tracing::warn;
use crate::handlers::shared::system_config_bool;
use crate::state::AppState;
pub(crate) const ENABLE_MODEL_DIRECTIVES_CONFIG_KEY: &str = "enable_model_directives";
pub(crate) const MODEL_DIRECTIVES_CONFIG_KEY: &str = "model_directives";
const REASONING_EFFORT_DIRECTIVE_KEY: &str = "reasoning_effort";
pub(crate) async fn model_directives_enabled(state: &AppState) -> bool {
match state
.read_system_config_json_value(ENABLE_MODEL_DIRECTIVES_CONFIG_KEY)
.await
{
Ok(value) => system_config_bool(value.as_ref(), false),
Err(error) => {
warn!(
error = ?error,
"gateway model directives config lookup failed"
);
false
}
}
}
pub(crate) async fn reasoning_model_directive_enabled(state: &AppState) -> bool {
model_directives_enabled(state).await
&& read_reasoning_model_directive_settings(state)
.await
.map(|settings| settings.enabled())
.unwrap_or(true)
}
pub(crate) async fn reasoning_model_directive_enabled_for_api_format(
state: &AppState,
api_format: &str,
) -> bool {
if !model_directives_enabled(state).await {
return false;
}
let settings = read_reasoning_model_directive_settings(state).await;
let enabled = settings
.as_ref()
.map(|settings| settings.enabled())
.unwrap_or(true);
if !enabled {
return false;
}
let api_format = crate::ai_serving::normalize_api_format_alias(api_format);
if api_format.is_empty() {
return false;
}
settings
.as_ref()
.and_then(|settings| settings.api_format_enabled(&api_format))
.unwrap_or(true)
}
pub(crate) async fn reasoning_model_directive_enabled_for_api_format_and_model(
state: &AppState,
api_format: &str,
requested_model: Option<&str>,
) -> bool {
if !model_directives_enabled(state).await {
return false;
}
let settings = read_reasoning_model_directive_settings(state).await;
let enabled = settings
.as_ref()
.map(|settings| settings.enabled())
.unwrap_or(true);
if !enabled {
return false;
}
let api_format = crate::ai_serving::normalize_api_format_alias(api_format);
if api_format.is_empty() {
return false;
}
let api_format_enabled = settings
.as_ref()
.and_then(|settings| settings.api_format_enabled(&api_format))
.unwrap_or(true);
if !api_format_enabled {
return false;
}
let Some(suffix) = requested_model.and_then(reasoning_suffix_from_model) else {
return false;
};
settings
.as_ref()
.and_then(|settings| settings.api_format_mappings(&api_format))
.map(|mappings| mappings.contains_key(&suffix))
.unwrap_or_else(|| DEFAULT_REASONING_SUFFIXES.contains(&suffix.as_str()))
}
pub(crate) async fn reasoning_model_directive_mapping_for_api_format_and_model(
state: &AppState,
api_format: &str,
requested_model: Option<&str>,
) -> Option<serde_json::Value> {
if !reasoning_model_directive_enabled_for_api_format_and_model(
state,
api_format,
requested_model,
)
.await
{
return None;
}
let suffix = requested_model.and_then(reasoning_suffix_from_model)?;
let api_format = crate::ai_serving::normalize_api_format_alias(api_format);
let settings = read_reasoning_model_directive_settings(state).await;
settings
.as_ref()
.and_then(|settings| settings.api_format_mappings(&api_format))
.and_then(|mappings| mappings.get(&suffix).cloned())
.or_else(|| default_reasoning_mapping(&api_format, &suffix))
}
const DEFAULT_REASONING_SUFFIXES: &[&str] = &["low", "medium", "high", "xhigh", "max"];
#[derive(Debug, Clone, Default)]
struct ReasoningModelDirectiveSettings {
enabled: Option<bool>,
api_formats: Option<serde_json::Map<String, serde_json::Value>>,
}
impl ReasoningModelDirectiveSettings {
fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
fn api_format_enabled(&self, api_format: &str) -> Option<bool> {
let api_formats = self.api_formats.as_ref()?;
api_formats.iter().find_map(|(key, value)| {
if crate::ai_serving::normalize_api_format_alias(key) != api_format {
return None;
}
Some(match value {
serde_json::Value::Object(object) => object
.get("enabled")
.map(|value| system_config_bool(Some(value), true))
.unwrap_or(true),
_ => system_config_bool(Some(value), true),
})
})
}
fn api_format_mappings(
&self,
api_format: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let api_formats = self.api_formats.as_ref()?;
api_formats.iter().find_map(|(key, value)| {
if crate::ai_serving::normalize_api_format_alias(key) != api_format {
return None;
}
let object = value.as_object()?;
if let Some(mappings) = object
.get("mappings")
.and_then(serde_json::Value::as_object)
{
return Some(normalize_reasoning_mappings(mappings));
}
let mappings = object
.get("suffixes")?
.as_array()?
.iter()
.filter_map(|value| value.as_str())
.filter_map(normalize_reasoning_suffix)
.filter_map(|suffix| {
default_reasoning_mapping(api_format, &suffix).map(|mapping| (suffix, mapping))
})
.collect::<serde_json::Map<_, _>>();
Some(mappings)
})
}
}
fn reasoning_suffix_from_model(model: &str) -> Option<String> {
let model = model.trim();
let (base_model, suffix) = model.rsplit_once('-')?;
if base_model.trim().is_empty() {
return None;
}
normalize_reasoning_suffix(suffix)
}
fn normalize_reasoning_suffix(suffix: &str) -> Option<String> {
let normalized = suffix.trim().to_ascii_lowercase();
DEFAULT_REASONING_SUFFIXES
.contains(&normalized.as_str())
.then_some(normalized)
}
fn normalize_reasoning_mappings(
mappings: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
mappings
.iter()
.filter_map(|(suffix, mapping)| {
normalize_reasoning_suffix(suffix).map(|suffix| (suffix, mapping.clone()))
})
.collect()
}
fn default_reasoning_mapping(api_format: &str, suffix: &str) -> Option<serde_json::Value> {
match api_format {
"openai:chat" => {
let effort = openai_reasoning_effort_value(suffix)?;
Some(serde_json::json!({ "reasoning_effort": effort }))
}
"openai:responses" | "openai:responses:compact" => {
let effort = openai_reasoning_effort_value(suffix)?;
Some(serde_json::json!({ "reasoning": { "effort": effort } }))
}
"claude:messages" => Some(serde_json::json!({
"thinking": {
"type": "enabled",
"budget_tokens": match suffix {
"low" => 1024,
"medium" => 4096,
"high" => 8192,
"xhigh" => 16384,
"max" => 32768,
_ => return None,
}
}
})),
"gemini:generate_content" => Some(serde_json::json!({
"generationConfig": {
"thinkingConfig": {
"thinkingBudget": match suffix {
"low" => 1024,
"medium" => 4096,
"high" => 8192,
"xhigh" => 16384,
"max" => -1,
_ => return None,
}
}
}
})),
_ => None,
}
}
fn openai_reasoning_effort_value(suffix: &str) -> Option<&'static str> {
match suffix {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" | "max" => Some("xhigh"),
_ => None,
}
}
async fn read_reasoning_model_directive_settings(
state: &AppState,
) -> Option<ReasoningModelDirectiveSettings> {
match state
.read_system_config_json_value(MODEL_DIRECTIVES_CONFIG_KEY)
.await
{
Ok(value) => parse_reasoning_model_directive_settings(value.as_ref()),
Err(error) => {
warn!(
error = ?error,
"gateway model directives detail config lookup failed"
);
None
}
}
}
fn parse_reasoning_model_directive_settings(
value: Option<&serde_json::Value>,
) -> Option<ReasoningModelDirectiveSettings> {
let root = value?.as_object()?;
let reasoning = root.get(REASONING_EFFORT_DIRECTIVE_KEY)?.as_object()?;
Some(ReasoningModelDirectiveSettings {
enabled: reasoning
.get("enabled")
.map(|value| system_config_bool(Some(value), true)),
api_formats: reasoning
.get("api_formats")
.and_then(serde_json::Value::as_object)
.cloned(),
})
}
#[cfg(test)]
mod tests {
use super::parse_reasoning_model_directive_settings;
use serde_json::json;
#[test]
fn reasoning_model_directive_settings_parse_endpoint_flags() {
let value = json!({
"reasoning_effort": {
"enabled": true,
"api_formats": {
"openai:chat": false,
"CLAUDE:MESSAGES": {
"enabled": true,
"mappings": {
"high": { "thinking": { "type": "enabled", "budget_tokens": 8192 } },
"max": { "thinking": { "type": "enabled", "budget_tokens": 32768 } }
}
}
}
}
});
let settings =
parse_reasoning_model_directive_settings(Some(&value)).expect("settings should parse");
assert!(settings.enabled());
assert_eq!(settings.api_format_enabled("openai:chat"), Some(false));
assert_eq!(settings.api_format_enabled("claude:messages"), Some(true));
assert_eq!(
settings
.api_format_mappings("claude:messages")
.and_then(|mappings| mappings.get("max").cloned()),
Some(json!({ "thinking": { "type": "enabled", "budget_tokens": 32768 } }))
);
assert_eq!(settings.api_format_enabled("gemini:generate_content"), None);
}
}

View File

@@ -976,6 +976,95 @@ async fn gateway_sets_admin_module_enabled_locally_with_trusted_admin_principal(
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_manages_model_directives_module_from_module_management() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/modules/status/model_directives/enabled",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let auth_module_repository = Arc::new(InMemoryAuthModuleReadRepository::default());
let data_state = GatewayDataState::with_auth_module_reader_for_tests(auth_module_repository)
.with_system_config_values_for_tests(Vec::<(String, serde_json::Value)>::new());
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/modules/status/model_directives"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["name"], "model_directives");
assert_eq!(payload["display_name"], "模型后缀参数");
assert_eq!(payload["enabled"], json!(false));
assert_eq!(payload["active"], json!(false));
assert_eq!(payload["config_validated"], json!(true));
assert_eq!(payload["admin_route"], "/admin/model-directives");
let response = reqwest::Client::new()
.put(format!(
"{gateway_url}/api/admin/modules/status/model_directives/enabled"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ "enabled": true }))
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["name"], "model_directives");
assert_eq!(payload["enabled"], json!(true));
assert_eq!(payload["active"], json!(true));
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/system/configs/enable_model_directives"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["key"], "enable_model_directives");
assert_eq!(payload["value"], json!(true));
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_management_tokens_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -1224,6 +1224,31 @@ async fn gateway_handles_admin_system_format_conversion_default_as_disabled() {
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_system_model_directives_default_as_disabled() {
let gateway = build_router_with_state(AppState::new().expect("gateway should build"));
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/system/configs/enable_model_directives"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["key"], "enable_model_directives");
assert_eq!(payload["value"], json!(false));
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_handles_admin_system_provider_priority_mode_locally_with_bearer_admin_session() {
let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -983,7 +983,7 @@ pub fn build_admin_module_validation_result(
)
}
}
"management_tokens" | "proxy_nodes" => (true, None),
"management_tokens" | "model_directives" | "proxy_nodes" => (true, None),
_ => (true, None),
}
}
@@ -993,7 +993,7 @@ pub fn build_admin_module_health(
gemini_files_has_capable_key: bool,
) -> &'static str {
match module_name {
"management_tokens" | "proxy_nodes" => "healthy",
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
"gemini_files" => {
if gemini_files_has_capable_key {
"healthy"
@@ -1225,6 +1225,64 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
"email_suffix_mode" => Some(json!("none")),
"email_suffix_list" => Some(json!([])),
"enable_format_conversion" => Some(json!(false)),
"enable_model_directives" => Some(json!(false)),
"model_directives" => Some(json!({
"reasoning_effort": {
"enabled": true,
"api_formats": {
"openai:chat": {
"enabled": true,
"mappings": {
"low": { "reasoning_effort": "low" },
"medium": { "reasoning_effort": "medium" },
"high": { "reasoning_effort": "high" },
"xhigh": { "reasoning_effort": "xhigh" },
"max": { "reasoning_effort": "xhigh" }
}
},
"openai:responses": {
"enabled": true,
"mappings": {
"low": { "reasoning": { "effort": "low" } },
"medium": { "reasoning": { "effort": "medium" } },
"high": { "reasoning": { "effort": "high" } },
"xhigh": { "reasoning": { "effort": "xhigh" } },
"max": { "reasoning": { "effort": "xhigh" } }
}
},
"openai:responses:compact": {
"enabled": true,
"mappings": {
"low": { "reasoning": { "effort": "low" } },
"medium": { "reasoning": { "effort": "medium" } },
"high": { "reasoning": { "effort": "high" } },
"xhigh": { "reasoning": { "effort": "xhigh" } },
"max": { "reasoning": { "effort": "xhigh" } }
}
},
"claude:messages": {
"enabled": true,
"mappings": {
"low": { "thinking": { "type": "enabled", "budget_tokens": 1024 } },
"medium": { "thinking": { "type": "enabled", "budget_tokens": 4096 } },
"high": { "thinking": { "type": "enabled", "budget_tokens": 8192 } },
"xhigh": { "thinking": { "type": "enabled", "budget_tokens": 16384 } },
"max": { "thinking": { "type": "enabled", "budget_tokens": 32768 } }
}
},
"gemini:generate_content": {
"enabled": true,
"mappings": {
"low": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 1024 } } },
"medium": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 4096 } } },
"high": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 8192 } } },
"xhigh": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 16384 } } },
"max": { "generationConfig": { "thinkingConfig": { "thinkingBudget": -1 } } }
}
}
}
}
})),
"keep_priority_on_conversion" => Some(json!(false)),
"audit_log_retention_days" => Some(json!(30)),
"enable_db_maintenance" => Some(json!(true)),

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,10 +12,13 @@ use crate::{
CanonicalRequest,
},
protocol::context::FormatContext,
request::openai::{
request::{
model_directives::claude_model_uses_adaptive_effort,
openai::{
map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_thinking_budget,
},
},
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
@@ -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,11 +103,29 @@ pub fn build_standard_request_body_from_canonical(
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
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(
"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,
@@ -93,7 +150,17 @@ pub fn build_standard_request_body_from_canonical(
upstream_is_stream,
),
_ => None,
}?;
if enable_model_directives {
apply_model_directive_overrides_from_request(
&mut provider_request_body,
provider_api_format,
mapped_model,
canonical_request,
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(
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(
) {

View File

@@ -52,11 +52,14 @@ pub struct SameFormatProviderRequestBehavior {
pub struct SameFormatProviderRequestBodyInput<'a> {
pub body_json: &'a Value,
pub mapped_model: &'a str,
pub provider_api_format: &'a str,
pub source_model: Option<&'a str>,
pub family: SameFormatProviderFamily,
pub body_rules: Option<&'a Value>,
pub upstream_is_stream: bool,
pub kiro_auth_config: Option<&'a KiroAuthConfig>,
pub is_claude_code: bool,
pub enable_model_directives: bool,
}
#[derive(Debug, Clone, Copy)]
@@ -154,6 +157,16 @@ pub fn build_same_format_provider_request_body(
if input.is_claude_code {
crate::claude_code::sanitize_claude_code_request_body(&mut provider_request_body);
}
if input.enable_model_directives {
if let Some(source_model) = input.source_model {
aether_ai_formats::apply_model_directive_overrides_from_model(
&mut provider_request_body,
input.provider_api_format,
input.mapped_model,
source_model,
);
}
}
if !apply_local_body_rules(
&mut provider_request_body,
input.body_rules,
@@ -475,11 +488,14 @@ mod tests {
"messages": [{"role": "user", "content": "hello"}]
}),
mapped_model: "upstream-model",
provider_api_format: "openai:chat",
source_model: Some("client-model"),
family: SameFormatProviderFamily::Standard,
body_rules: None,
upstream_is_stream: true,
kiro_auth_config: None,
is_claude_code: false,
enable_model_directives: false,
})
.expect("body should build");
@@ -487,6 +503,33 @@ mod tests {
assert_eq!(body.get("stream"), Some(&json!(true)));
}
#[test]
fn same_format_body_applies_model_directive_before_body_rules() {
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
body_json: &json!({
"model": "gpt-5.4-high",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "low"
}),
mapped_model: "upstream-model",
provider_api_format: "openai:chat",
source_model: Some("gpt-5.4-high"),
family: SameFormatProviderFamily::Standard,
body_rules: Some(&json!([
{"action":"set","path":"metadata.body_rule_seen","value":true}
])),
upstream_is_stream: false,
kiro_auth_config: None,
is_claude_code: false,
enable_model_directives: true,
})
.expect("body should build");
assert_eq!(body["model"], "upstream-model");
assert_eq!(body["reasoning_effort"], "high");
assert_eq!(body["metadata"]["body_rule_seen"], true);
}
#[test]
fn builds_same_format_headers_with_auth_and_stream_accept() {
let provider_request_body = json!({"model": "upstream-model"});

View File

@@ -63,23 +63,44 @@ pub fn auth_constraints_allow_model(
constraints: Option<&SchedulerAuthConstraints>,
requested_model_name: &str,
resolved_global_model_name: &str,
) -> bool {
auth_constraints_allow_model_with_model_directives(
constraints,
requested_model_name,
resolved_global_model_name,
false,
)
}
pub fn auth_constraints_allow_model_with_model_directives(
constraints: Option<&SchedulerAuthConstraints>,
requested_model_name: &str,
resolved_global_model_name: &str,
enable_model_directives: bool,
) -> bool {
let Some(allowed) = constraints.and_then(|constraints| constraints.allowed_models.as_deref())
else {
return true;
};
allowed
.iter()
.any(|value| value == requested_model_name || value == resolved_global_model_name)
let base_model = enable_model_directives
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
.flatten();
allowed.iter().any(|value| {
value == requested_model_name
|| value == resolved_global_model_name
|| base_model
.as_ref()
.is_some_and(|base_model| value == base_model)
})
}
#[cfg(test)]
mod tests {
use super::{
api_format_matches_allowed_value, auth_constraints_allow_api_format,
auth_constraints_allow_model, auth_constraints_allow_provider,
provider_matches_allowed_value, SchedulerAuthConstraints,
auth_constraints_allow_model, auth_constraints_allow_model_with_model_directives,
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
};
fn sample_constraints() -> SchedulerAuthConstraints {
@@ -200,6 +221,23 @@ mod tests {
));
}
#[test]
fn model_directive_base_model_requires_explicit_enablement() {
let constraints = sample_constraints();
assert!(!auth_constraints_allow_model(
Some(&constraints),
"gpt-5-high",
"gpt-5-high"
));
assert!(auth_constraints_allow_model_with_model_directives(
Some(&constraints),
"gpt-5-high",
"gpt-5-high",
true
));
}
#[test]
fn api_format_allowed_value_matches_current_signatures_only() {
assert!(api_format_matches_allowed_value(

View File

@@ -9,6 +9,20 @@ use super::types::{
pub fn enumerate_minimal_candidate_selection(
input: EnumerateMinimalCandidateSelectionInput<'_>,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
enumerate_minimal_candidate_selection_inner(input, false)
}
pub fn enumerate_minimal_candidate_selection_with_model_directives(
input: EnumerateMinimalCandidateSelectionInput<'_>,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
enumerate_minimal_candidate_selection_inner(input, enable_model_directives)
}
fn enumerate_minimal_candidate_selection_inner(
input: EnumerateMinimalCandidateSelectionInput<'_>,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
let EnumerateMinimalCandidateSelectionInput {
rows,
@@ -26,10 +40,11 @@ pub fn enumerate_minimal_candidate_selection(
if !crate::auth_constraints_allow_api_format(auth_constraints, normalized_api_format) {
return Ok(Vec::new());
}
if !crate::auth_constraints_allow_model(
if !crate::auth_constraints_allow_model_with_model_directives(
auth_constraints,
requested_model_name,
resolved_global_model_name,
enable_model_directives,
) {
return Ok(Vec::new());
}
@@ -48,7 +63,12 @@ pub fn enumerate_minimal_candidate_selection(
continue;
}
let Some((selected_provider_model_name, mapping_matched_model)) =
crate::resolve_provider_model_name(&row, requested_model_name, normalized_api_format)
crate::resolve_provider_model_name_with_model_directives(
&row,
requested_model_name,
normalized_api_format,
enable_model_directives,
)
else {
continue;
};

View File

@@ -8,6 +8,7 @@ pub use capability::{
};
pub use enumeration::{
collect_global_model_names_for_required_capability, enumerate_minimal_candidate_selection,
enumerate_minimal_candidate_selection_with_model_directives,
};
pub use selectability::{
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,

View File

@@ -13,13 +13,14 @@ pub use affinity::{
};
pub use auth::{
api_format_matches_allowed_value, auth_constraints_allow_api_format,
auth_constraints_allow_model, auth_constraints_allow_provider, provider_matches_allowed_value,
SchedulerAuthConstraints,
auth_constraints_allow_model, auth_constraints_allow_model_with_model_directives,
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
};
pub use candidate::{
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,
candidate_runtime_skip_reason_with_state, candidate_supports_required_capability,
collect_global_model_names_for_required_capability, enumerate_minimal_candidate_selection,
enumerate_minimal_candidate_selection_with_model_directives,
requested_capability_priority_for_candidate, CandidateRuntimeSelectabilityInput,
EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate,
SchedulerPriorityMode,
@@ -35,8 +36,11 @@ pub use health::{
};
pub use model::{
candidate_model_names, extract_global_priority_for_format, matches_model_mapping,
normalize_api_format, resolve_provider_model_name, resolve_requested_global_model_name,
row_supports_requested_model, row_supports_required_capability, select_provider_model_name,
normalize_api_format, resolve_provider_model_name,
resolve_provider_model_name_with_model_directives, resolve_requested_global_model_name,
resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
row_supports_requested_model_with_model_directives, row_supports_required_capability,
select_provider_model_name,
};
pub use provider::{build_provider_concurrent_limit_map, should_skip_provider_quota};
pub use ranking::{

View File

@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeSet;
use aether_data_contracts::repository::candidate_selection::{
@@ -11,6 +12,23 @@ pub fn resolve_requested_global_model_name(
requested_model_name: &str,
api_format: &str,
) -> Option<String> {
resolve_requested_global_model_name_with_model_directives(
rows,
requested_model_name,
api_format,
false,
)
}
pub fn resolve_requested_global_model_name_with_model_directives(
rows: &[StoredMinimalCandidateSelectionRow],
requested_model_name: &str,
api_format: &str,
enable_model_directives: bool,
) -> Option<String> {
requested_model_name_candidates(requested_model_name, enable_model_directives).find_map(
|requested_model_name| {
let requested_model_name = requested_model_name.as_ref();
resolve_global_model_name_by(rows, |row| row.global_model_name == requested_model_name)
.or_else(|| {
resolve_global_model_name_by(rows, |row| {
@@ -38,12 +56,35 @@ pub fn resolve_requested_global_model_name(
})
})
})
},
)
}
pub fn row_supports_requested_model(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
) -> bool {
row_supports_requested_model_with_model_directives(row, requested_model_name, api_format, false)
}
pub fn row_supports_requested_model_with_model_directives(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
enable_model_directives: bool,
) -> bool {
requested_model_name_candidates(requested_model_name, enable_model_directives).any(
|requested_model_name| {
row_supports_requested_model_exact(row, requested_model_name.as_ref(), api_format)
},
)
}
fn row_supports_requested_model_exact(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
) -> bool {
row.global_model_name == requested_model_name
|| row.model_provider_model_name == requested_model_name
@@ -87,6 +128,15 @@ pub fn resolve_provider_model_name(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
) -> Option<(String, Option<String>)> {
resolve_provider_model_name_with_model_directives(row, requested_model_name, api_format, false)
}
pub fn resolve_provider_model_name_with_model_directives(
row: &StoredMinimalCandidateSelectionRow,
requested_model_name: &str,
api_format: &str,
enable_model_directives: bool,
) -> Option<(String, Option<String>)> {
let selected_provider_model_name = select_provider_model_name(row, api_format);
let Some(key_allowed_models) = row.key_allowed_models.as_ref() else {
@@ -103,6 +153,16 @@ pub fn resolve_provider_model_name(
return Some((selected_provider_model_name, None));
}
if enable_model_directives {
if let Some(base_model) =
aether_ai_formats::model_directive_base_model(requested_model_name)
{
if key_allowed_models.iter().any(|value| value == &base_model) {
return Some((selected_provider_model_name, Some(base_model)));
}
}
}
let mut sorted_allowed_models = key_allowed_models
.iter()
.map(String::as_str)
@@ -302,9 +362,26 @@ fn api_format_matches(left: &str, right: &str) -> bool {
normalize_api_format(left) == normalize_api_format(right)
}
fn requested_model_name_candidates(
requested_model_name: &str,
enable_model_directives: bool,
) -> impl Iterator<Item = Cow<'_, str>> {
let requested_model_name = requested_model_name.trim();
let base_model = enable_model_directives
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
.flatten();
std::iter::once(Cow::Borrowed(requested_model_name)).chain(base_model.map(Cow::Owned))
}
#[cfg(test)]
mod tests {
use super::matches_model_mapping;
use super::{
matches_model_mapping, resolve_provider_model_name,
resolve_provider_model_name_with_model_directives,
resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
row_supports_requested_model_with_model_directives,
};
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
#[test]
fn model_mapping_match_is_case_insensitive() {
@@ -322,4 +399,103 @@ mod tests {
fn invalid_model_mapping_pattern_returns_false() {
assert!(!matches_model_mapping("([a-z", "gpt-4o"));
}
#[test]
fn model_directive_suffix_matches_base_model_as_fallback() {
let row = sample_row("gpt-5.4", "gpt-5.4-upstream");
assert!(!row_supports_requested_model(
&row,
"gpt-5.4-xhigh",
"openai:chat"
));
assert!(row_supports_requested_model_with_model_directives(
&row,
"gpt-5.4-xhigh",
"openai:chat",
true
));
assert_eq!(
resolve_requested_global_model_name_with_model_directives(
&[row],
"gpt-5.4-xhigh",
"openai:chat",
true
)
.as_deref(),
Some("gpt-5.4")
);
}
#[test]
fn model_directive_suffix_prefers_exact_model_before_base_fallback() {
let exact = sample_row("gpt-5.4-high", "gpt-5.4-high-upstream");
let base = sample_row("gpt-5.4", "gpt-5.4-upstream");
assert_eq!(
resolve_requested_global_model_name_with_model_directives(
&[base, exact],
"gpt-5.4-high",
"openai:chat",
true
)
.as_deref(),
Some("gpt-5.4-high")
);
}
#[test]
fn model_directive_base_model_satisfies_key_allowed_models() {
let mut row = sample_row("gpt-5.4", "gpt-5.4-upstream");
row.key_allowed_models = Some(vec!["gpt-5.4".to_string()]);
assert!(resolve_provider_model_name(&row, "gpt-5.4-max", "openai:chat").is_none());
let resolved = resolve_provider_model_name_with_model_directives(
&row,
"gpt-5.4-max",
"openai:chat",
true,
)
.expect("base model should satisfy key allowed models");
assert_eq!(resolved.0, "gpt-5.4-upstream");
assert_eq!(resolved.1.as_deref(), Some("gpt-5.4"));
}
fn sample_row(
global_model_name: &str,
model_provider_model_name: &str,
) -> StoredMinimalCandidateSelectionRow {
StoredMinimalCandidateSelectionRow {
provider_id: "provider-1".to_string(),
provider_name: "Provider".to_string(),
provider_type: "openai".to_string(),
provider_priority: 0,
provider_is_active: true,
endpoint_id: "endpoint-1".to_string(),
endpoint_api_format: "openai:chat".to_string(),
endpoint_api_family: None,
endpoint_kind: None,
endpoint_is_active: true,
key_id: "key-1".to_string(),
key_name: "Key".to_string(),
key_auth_type: "api_key".to_string(),
key_is_active: true,
key_api_formats: None,
key_allowed_models: None,
key_capabilities: None,
key_internal_priority: 0,
key_global_priority_by_format: None,
model_id: format!("model-{global_model_name}"),
global_model_id: format!("global-{global_model_name}"),
global_model_name: global_model_name.to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: model_provider_model_name.to_string(),
model_provider_model_mappings: None,
model_supports_streaming: Some(true),
model_is_active: true,
model_is_available: true,
}
}
}

View File

@@ -381,6 +381,7 @@ import {
Zap,
FileUp,
Server,
SlidersHorizontal,
type LucideIcon,
} from 'lucide-vue-next'
@@ -559,6 +560,7 @@ const navigation = computed(() => {
Shield,
Puzzle,
Server,
SlidersHorizontal,
}
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)

View File

@@ -220,6 +220,12 @@ const routes: RouteRecordRaw[] = [
name: 'ModuleManagement',
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
},
{
path: 'model-directives',
name: 'ModelDirectivesManagement',
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
meta: { module: 'model_directives' }
},
{
path: 'email',
name: 'EmailSettings',

View File

@@ -0,0 +1,97 @@
<template>
<PageContainer>
<PageHeader
title="模型后缀参数"
description="允许通过模型名后缀覆盖推理参数"
>
<template #actions>
<Button
variant="outline"
:disabled="loading"
@click="loadConfig"
>
<RefreshCw
class="w-4 h-4 mr-2"
:class="{ 'animate-spin': loading }"
/>
刷新
</Button>
</template>
</PageHeader>
<div class="mt-6 space-y-5">
<Card
variant="default"
class="p-6"
>
<ModelDirectivesPanel
:config="modelDirectivesConfig"
:loading="loading || saving"
@save="saveConfig"
@update:config="modelDirectivesConfig = $event"
/>
</Card>
</div>
</PageContainer>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue'
import { PageContainer, PageHeader } from '@/components/layout'
import { adminApi } from '@/api/admin'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { getErrorMessage } from '@/types/api-error'
import ModelDirectivesPanel from './module-management/ModelDirectivesPanel.vue'
import {
createDefaultModelDirectivesConfig,
normalizeModelDirectivesConfig,
type ModelDirectivesConfig,
} from './module-management/modelDirectivesConfig'
const { success, error } = useToast()
const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig())
const loading = ref(false)
const saving = ref(false)
async function loadConfig() {
loading.value = true
try {
const response = await adminApi.getSystemConfig('model_directives')
const normalized = normalizeModelDirectivesConfig(response.value)
modelDirectivesConfig.value = normalized
} catch (err) {
error('获取模型后缀参数配置失败')
log.error('获取模型后缀参数配置失败:', err)
} finally {
loading.value = false
}
}
async function saveConfig() {
saving.value = true
try {
const normalized = normalizeModelDirectivesConfig(modelDirectivesConfig.value)
modelDirectivesConfig.value = normalized
await adminApi.updateSystemConfig(
'model_directives',
normalized,
'模型后缀参数配置'
)
success('模型后缀参数配置已保存')
} catch (err) {
error(getErrorMessage(err, '保存模型后缀参数配置失败'))
log.error('保存模型后缀参数配置失败:', err)
} finally {
saving.value = false
}
}
onMounted(() => {
loadConfig()
})
</script>

View File

@@ -0,0 +1,213 @@
<template>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 class="text-base font-semibold">
推理参数
</h3>
<p class="mt-1 text-sm text-muted-foreground">
各端点可以分别启用推理参数并配置推理程度到实际请求参数和值的映射
</p>
</div>
<div class="flex items-center">
<Switch
:model-value="config.reasoning_effort.enabled"
:disabled="loading"
@update:model-value="onReasoningEnabledChange"
/>
</div>
</div>
<div class="overflow-hidden rounded-lg border">
<div class="grid grid-cols-1 gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]">
<div>API 端点</div>
<div>推理程度</div>
<div>映射参数</div>
<div class="md:text-right">状态</div>
</div>
<div class="divide-y">
<div
v-for="format in MODEL_DIRECTIVE_API_FORMATS"
:key="format.key"
class="grid grid-cols-1 items-center gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]"
>
<div>
<div class="text-sm font-medium">
{{ format.label }}
</div>
</div>
<div>
<Select
:model-value="selectedEfforts[format.key] ?? 'low'"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
@update:model-value="value => selectEffort(format.key, value)"
>
<SelectTrigger class="h-9 w-28 rounded-lg">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="effort in DEFAULT_REASONING_SUFFIXES"
:key="effort"
:value="effort"
>
{{ effort }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-2">
<Input
v-model="localMappingParams[mappingKey(format.key)]"
class="h-9 font-mono text-xs"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
placeholder="{&quot;reasoning_effort&quot;:&quot;low&quot;}"
/>
<Button
size="icon"
variant="ghost"
class="h-7 w-7 shrink-0 text-muted-foreground"
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !hasMappingParamChanges(format.key)"
@click="saveMappingParam(format.key)"
>
<Save class="w-3.5 h-3.5" />
</Button>
</div>
<div class="flex items-center md:justify-end">
<Switch
:model-value="formatConfig(format.key).enabled"
:disabled="loading || !config.reasoning_effort.enabled"
@update:model-value="value => onApiFormatEnabledChange(format.key, value)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { Save } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import Switch from '@/components/ui/switch.vue'
import Input from '@/components/ui/input.vue'
import {
DEFAULT_REASONING_SUFFIXES,
MODEL_DIRECTIVE_API_FORMATS,
type ReasoningApiFormatConfig,
type ModelDirectivesConfig,
} from './modelDirectivesConfig'
const props = defineProps<{
config: ModelDirectivesConfig
loading: boolean
}>()
const emit = defineEmits<{
save: []
'update:config': [value: ModelDirectivesConfig]
}>()
const selectedEfforts = reactive<Record<string, string>>({})
const localMappingParams = reactive<Record<string, string>>({})
watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
for (const format of MODEL_DIRECTIVE_API_FORMATS) {
const fc = newFormats[format.key]
const selectedEffort = selectedEfforts[format.key] ?? firstMappingEffort(fc?.mappings) ?? 'low'
selectedEfforts[format.key] = selectedEffort
const key = mappingKey(format.key, selectedEffort)
if (localMappingParams[key] === undefined || !hasMappingParamChanges(format.key)) {
localMappingParams[key] = JSON.stringify(fc?.mappings?.[selectedEffort] ?? {}, null, 2)
}
}
}, { immediate: true })
function formatConfig(apiFormat: string): ReasoningApiFormatConfig {
return props.config.reasoning_effort.api_formats[apiFormat] ?? {
enabled: true,
mappings: {},
}
}
function firstMappingEffort(mappings: Record<string, unknown> | undefined): string | undefined {
return DEFAULT_REASONING_SUFFIXES.find((effort) => mappings?.[effort] !== undefined)
}
function mappingKey(apiFormat: string, effort = selectedEfforts[apiFormat] ?? 'low'): string {
return `${apiFormat}:${effort}`
}
function selectEffort(apiFormat: string, effort: string) {
selectedEfforts[apiFormat] = effort
const key = mappingKey(apiFormat, effort)
localMappingParams[key] = JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
}
function hasMappingParamChanges(apiFormat: string): boolean {
const effort = selectedEfforts[apiFormat] ?? 'low'
return (localMappingParams[mappingKey(apiFormat, effort)] ?? '') !== JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
}
function onReasoningEnabledChange(value: boolean) {
emit('update:config', {
...props.config,
reasoning_effort: { ...props.config.reasoning_effort, enabled: Boolean(value) },
})
emit('save')
}
function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
const current = formatConfig(apiFormat)
emit('update:config', {
...props.config,
reasoning_effort: {
...props.config.reasoning_effort,
api_formats: {
...props.config.reasoning_effort.api_formats,
[apiFormat]: { ...current, enabled: Boolean(value) },
},
},
})
emit('save')
}
function saveMappingParam(apiFormat: string) {
const current = formatConfig(apiFormat)
const effort = selectedEfforts[apiFormat] ?? 'low'
let mapping: unknown
try {
const parsed = JSON.parse(localMappingParams[mappingKey(apiFormat, effort)] || '{}')
mapping = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(current.mappings[effort] ?? {}, null, 2)
return
}
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(mapping, null, 2)
emit('update:config', {
...props.config,
reasoning_effort: {
...props.config.reasoning_effort,
api_formats: {
...props.config.reasoning_effort.api_formats,
[apiFormat]: {
...current,
mappings: {
...current.mappings,
[effort]: mapping,
},
},
},
},
})
emit('save')
}
</script>

View File

@@ -0,0 +1,156 @@
export interface ReasoningApiFormatConfig {
enabled: boolean
mappings: Record<string, unknown>
}
export interface ModelDirectivesConfig {
reasoning_effort: {
enabled: boolean
api_formats: Record<string, ReasoningApiFormatConfig>
}
}
export const MODEL_DIRECTIVES_MODULE_NAME = 'model_directives'
export const MODEL_DIRECTIVE_API_FORMATS = [
{
key: 'openai:chat',
label: 'OpenAI Chat',
parameter: 'reasoning_effort',
},
{
key: 'openai:responses',
label: 'OpenAI Responses',
parameter: 'reasoning.effort',
},
{
key: 'openai:responses:compact',
label: 'OpenAI Responses Compact',
parameter: 'reasoning.effort',
},
{
key: 'claude:messages',
label: 'Claude Messages',
parameter: 'output_config.effort + thinking',
},
{
key: 'gemini:generate_content',
label: 'Gemini GenerateContent',
parameter: 'generationConfig.thinkingConfig',
},
] as const
export const DEFAULT_REASONING_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const
function defaultMappingsForApiFormat(apiFormat: string): Record<string, unknown> {
switch (apiFormat) {
case 'openai:chat':
return {
low: { reasoning_effort: 'low' },
medium: { reasoning_effort: 'medium' },
high: { reasoning_effort: 'high' },
xhigh: { reasoning_effort: 'xhigh' },
max: { reasoning_effort: 'xhigh' },
}
case 'openai:responses':
case 'openai:responses:compact':
return {
low: { reasoning: { effort: 'low' } },
medium: { reasoning: { effort: 'medium' } },
high: { reasoning: { effort: 'high' } },
xhigh: { reasoning: { effort: 'xhigh' } },
max: { reasoning: { effort: 'xhigh' } },
}
case 'claude:messages':
return {
low: { thinking: { type: 'enabled', budget_tokens: 1024 } },
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
high: { thinking: { type: 'enabled', budget_tokens: 8192 } },
xhigh: { thinking: { type: 'enabled', budget_tokens: 16384 } },
max: { thinking: { type: 'enabled', budget_tokens: 32768 } },
}
case 'gemini:generate_content':
return {
low: { generationConfig: { thinkingConfig: { thinkingBudget: 1024 } } },
medium: { generationConfig: { thinkingConfig: { thinkingBudget: 4096 } } },
high: { generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } },
xhigh: { generationConfig: { thinkingConfig: { thinkingBudget: 16384 } } },
max: { generationConfig: { thinkingConfig: { thinkingBudget: -1 } } },
}
default:
return {}
}
}
export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
return {
reasoning_effort: {
enabled: true,
api_formats: Object.fromEntries(
MODEL_DIRECTIVE_API_FORMATS.map((format) => [
format.key,
{
enabled: true,
mappings: defaultMappingsForApiFormat(format.key),
},
])
),
},
}
}
function mappingsFromLegacySuffixes(apiFormat: string, value: unknown): Record<string, unknown> {
if (!Array.isArray(value)) return defaultMappingsForApiFormat(apiFormat)
const supported = new Set<string>(DEFAULT_REASONING_SUFFIXES)
const defaults = defaultMappingsForApiFormat(apiFormat)
return Object.fromEntries(value
.map((item) => String(item).trim().toLowerCase())
.filter((item, index, array) => supported.has(item) && array.indexOf(item) === index)
.map((suffix) => [suffix, defaults[suffix]])
.filter(([, mapping]) => mapping !== undefined))
}
function normalizeMappings(apiFormat: string, value: unknown, legacySuffixes: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return mappingsFromLegacySuffixes(apiFormat, legacySuffixes)
}
return { ...(value as Record<string, unknown>) }
}
export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesConfig {
const defaults = createDefaultModelDirectivesConfig()
if (!value || typeof value !== 'object') return defaults
const source = value as Partial<ModelDirectivesConfig>
const reasoning = source.reasoning_effort
const apiFormats: Record<string, ReasoningApiFormatConfig> = {
...defaults.reasoning_effort.api_formats,
}
const sourceApiFormats = reasoning?.api_formats
if (sourceApiFormats && typeof sourceApiFormats === 'object') {
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) {
if (typeof rawConfig === 'boolean') {
apiFormats[apiFormat] = {
enabled: rawConfig,
mappings: defaultMappingsForApiFormat(apiFormat),
}
} else if (rawConfig && typeof rawConfig === 'object') {
const value = rawConfig as Partial<ReasoningApiFormatConfig> & { suffixes?: unknown }
apiFormats[apiFormat] = {
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
mappings: normalizeMappings(apiFormat, value.mappings, value.suffixes),
}
}
}
}
return {
reasoning_effort: {
enabled:
typeof reasoning?.enabled === 'boolean'
? reasoning.enabled
: defaults.reasoning_effort.enabled,
api_formats: apiFormats,
},
}
}