mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: add model directive management
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -57,6 +57,7 @@ mod request_candidate_runtime;
|
||||
mod router;
|
||||
mod scheduler;
|
||||
mod state;
|
||||
mod system_features;
|
||||
mod tunnel;
|
||||
mod usage;
|
||||
mod video_tasks;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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 &=
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
335
apps/aether-gateway/src/system_features.rs
Normal file
335
apps/aether-gateway/src/system_features.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user