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,21 +90,29 @@ 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 {
rows,
normalized_api_format: &normalized_api_format,
requested_model_name,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming,
required_capabilities,
auth_constraints: auth_constraints.as_ref(),
})
enumerate_minimal_candidate_selection_with_model_directives(
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format: &normalized_api_format,
requested_model_name,
resolved_global_model_name: resolved_global_model_name.as_str(),
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));