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,13 +90,19 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let Some((resolved_global_model_name, rows)) =
|
||||
read_requested_model_rows(state, &normalized_api_format, requested_model_name).await?
|
||||
let Some((resolved_global_model_name, rows)) = read_requested_model_rows(
|
||||
state,
|
||||
&normalized_api_format,
|
||||
requested_model_name,
|
||||
enable_model_directives,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let auth_constraints = auth_snapshot.map(auth_snapshot_constraints);
|
||||
enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput {
|
||||
enumerate_minimal_candidate_selection_with_model_directives(
|
||||
EnumerateMinimalCandidateSelectionInput {
|
||||
rows,
|
||||
normalized_api_format: &normalized_api_format,
|
||||
requested_model_name,
|
||||
@@ -90,7 +110,9 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili
|
||||
require_streaming,
|
||||
required_capabilities,
|
||||
auth_constraints: auth_constraints.as_ref(),
|
||||
})
|
||||
},
|
||||
enable_model_directives,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_global_model_names_for_required_capability(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -983,7 +983,7 @@ pub fn build_admin_module_validation_result(
|
||||
)
|
||||
}
|
||||
}
|
||||
"management_tokens" | "proxy_nodes" => (true, None),
|
||||
"management_tokens" | "model_directives" | "proxy_nodes" => (true, None),
|
||||
_ => (true, None),
|
||||
}
|
||||
}
|
||||
@@ -993,7 +993,7 @@ pub fn build_admin_module_health(
|
||||
gemini_files_has_capable_key: bool,
|
||||
) -> &'static str {
|
||||
match module_name {
|
||||
"management_tokens" | "proxy_nodes" => "healthy",
|
||||
"management_tokens" | "model_directives" | "proxy_nodes" => "healthy",
|
||||
"gemini_files" => {
|
||||
if gemini_files_has_capable_key {
|
||||
"healthy"
|
||||
@@ -1225,6 +1225,64 @@ pub fn admin_system_config_default_value(key: &str) -> Option<serde_json::Value>
|
||||
"email_suffix_mode" => Some(json!("none")),
|
||||
"email_suffix_list" => Some(json!([])),
|
||||
"enable_format_conversion" => Some(json!(false)),
|
||||
"enable_model_directives" => Some(json!(false)),
|
||||
"model_directives" => Some(json!({
|
||||
"reasoning_effort": {
|
||||
"enabled": true,
|
||||
"api_formats": {
|
||||
"openai:chat": {
|
||||
"enabled": true,
|
||||
"mappings": {
|
||||
"low": { "reasoning_effort": "low" },
|
||||
"medium": { "reasoning_effort": "medium" },
|
||||
"high": { "reasoning_effort": "high" },
|
||||
"xhigh": { "reasoning_effort": "xhigh" },
|
||||
"max": { "reasoning_effort": "xhigh" }
|
||||
}
|
||||
},
|
||||
"openai:responses": {
|
||||
"enabled": true,
|
||||
"mappings": {
|
||||
"low": { "reasoning": { "effort": "low" } },
|
||||
"medium": { "reasoning": { "effort": "medium" } },
|
||||
"high": { "reasoning": { "effort": "high" } },
|
||||
"xhigh": { "reasoning": { "effort": "xhigh" } },
|
||||
"max": { "reasoning": { "effort": "xhigh" } }
|
||||
}
|
||||
},
|
||||
"openai:responses:compact": {
|
||||
"enabled": true,
|
||||
"mappings": {
|
||||
"low": { "reasoning": { "effort": "low" } },
|
||||
"medium": { "reasoning": { "effort": "medium" } },
|
||||
"high": { "reasoning": { "effort": "high" } },
|
||||
"xhigh": { "reasoning": { "effort": "xhigh" } },
|
||||
"max": { "reasoning": { "effort": "xhigh" } }
|
||||
}
|
||||
},
|
||||
"claude:messages": {
|
||||
"enabled": true,
|
||||
"mappings": {
|
||||
"low": { "thinking": { "type": "enabled", "budget_tokens": 1024 } },
|
||||
"medium": { "thinking": { "type": "enabled", "budget_tokens": 4096 } },
|
||||
"high": { "thinking": { "type": "enabled", "budget_tokens": 8192 } },
|
||||
"xhigh": { "thinking": { "type": "enabled", "budget_tokens": 16384 } },
|
||||
"max": { "thinking": { "type": "enabled", "budget_tokens": 32768 } }
|
||||
}
|
||||
},
|
||||
"gemini:generate_content": {
|
||||
"enabled": true,
|
||||
"mappings": {
|
||||
"low": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 1024 } } },
|
||||
"medium": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 4096 } } },
|
||||
"high": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 8192 } } },
|
||||
"xhigh": { "generationConfig": { "thinkingConfig": { "thinkingBudget": 16384 } } },
|
||||
"max": { "generationConfig": { "thinkingConfig": { "thinkingBudget": -1 } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
"keep_priority_on_conversion" => Some(json!(false)),
|
||||
"audit_log_retention_days" => Some(json!(30)),
|
||||
"enable_db_maintenance" => Some(json!(true)),
|
||||
|
||||
@@ -61,7 +61,17 @@ pub use crate::provider_compat::surfaces::{
|
||||
pub use crate::request::common::{
|
||||
force_upstream_streaming_for_provider, parse_direct_request_body,
|
||||
};
|
||||
pub use crate::request::matrix::build_standard_request_body_from_canonical;
|
||||
pub use crate::request::matrix::{
|
||||
build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_from_canonical_with_model_directives,
|
||||
};
|
||||
pub use crate::request::model_directives::{
|
||||
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
|
||||
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
|
||||
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
|
||||
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
|
||||
ReasoningEffort,
|
||||
};
|
||||
pub use crate::request::openai::{
|
||||
copy_request_number_field, copy_request_number_field_as,
|
||||
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
|
||||
@@ -98,8 +108,14 @@ pub use crate::request::specialized::{
|
||||
pub use crate::request::standard::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
apply_openai_responses_compact_special_body_edits, build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_responses_request_body, build_standard_request_body,
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_request_body_with_model_directives,
|
||||
build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives, build_standard_request_body,
|
||||
build_standard_request_body_with_model_directives,
|
||||
claude::{
|
||||
resolve_stream_spec as resolve_claude_stream_spec,
|
||||
resolve_sync_spec as resolve_claude_sync_spec,
|
||||
|
||||
@@ -36,3 +36,10 @@ pub use protocol::matrix::{
|
||||
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
|
||||
};
|
||||
pub use protocol::registry::{build_stream_transcoder, convert_request, convert_response};
|
||||
pub use request::model_directives::{
|
||||
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
|
||||
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
|
||||
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
|
||||
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
|
||||
ReasoningEffort,
|
||||
};
|
||||
|
||||
@@ -2503,7 +2503,8 @@ pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Op
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"max" | "xhigh" => Some("xhigh"),
|
||||
"xhigh" => Some("xhigh"),
|
||||
"max" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ use crate::{
|
||||
CanonicalRequest,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
request::openai::{
|
||||
request::{
|
||||
model_directives::claude_model_uses_adaptive_effort,
|
||||
openai::{
|
||||
map_openai_reasoning_effort_to_claude_output,
|
||||
map_openai_reasoning_effort_to_thinking_budget,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
@@ -155,14 +158,18 @@ pub fn to_raw(
|
||||
let budget_tokens = thinking
|
||||
.budget_tokens
|
||||
.or_else(|| openai_effort.and_then(map_openai_reasoning_effort_to_thinking_budget));
|
||||
let uses_adaptive = claude_model_uses_adaptive_effort(mapped_model)
|
||||
|| claude_model_uses_adaptive_effort(canonical.model.as_str());
|
||||
if thinking.enabled || budget_tokens.is_some() {
|
||||
output.insert(
|
||||
"thinking".to_string(),
|
||||
let thinking_config = if uses_adaptive {
|
||||
json!({"type": "adaptive"})
|
||||
} else {
|
||||
json!({
|
||||
"type": "enabled",
|
||||
"budget_tokens": budget_tokens.unwrap_or(1024),
|
||||
}),
|
||||
);
|
||||
})
|
||||
};
|
||||
output.insert("thinking".to_string(), thinking_config);
|
||||
}
|
||||
if let Some(output_effort) =
|
||||
openai_effort.and_then(map_openai_reasoning_effort_to_claude_output)
|
||||
|
||||
@@ -15,7 +15,13 @@ use crate::{
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
protocol::context::FormatContext,
|
||||
request::openai::map_openai_reasoning_effort_to_gemini_budget,
|
||||
request::{
|
||||
model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
|
||||
openai::{
|
||||
map_openai_reasoning_effort_to_gemini_budget,
|
||||
map_thinking_budget_to_openai_reasoning_effort,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
@@ -203,7 +209,8 @@ fn canonical_to_gemini_request_body(
|
||||
if let Some(system_instruction) = canonical_system_instruction(canonical) {
|
||||
output.insert("systemInstruction".to_string(), system_instruction);
|
||||
}
|
||||
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical) {
|
||||
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical, mapped_model)
|
||||
{
|
||||
output.insert("generationConfig".to_string(), generation_config);
|
||||
}
|
||||
if let Some(tools) = canonical_tools_to_gemini(canonical) {
|
||||
@@ -370,7 +377,10 @@ fn canonical_media_to_gemini_part(
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
fn canonical_generation_config_to_gemini(
|
||||
canonical: &CanonicalRequest,
|
||||
mapped_model: &str,
|
||||
) -> Option<Value> {
|
||||
let mut generation_config = Map::new();
|
||||
if let Some(value) = canonical.generation.max_tokens {
|
||||
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
|
||||
@@ -406,14 +416,8 @@ fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option
|
||||
.and_then(|value| value.get("thinking_config"))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
let budget = thinking.budget_tokens.or_else(|| {
|
||||
canonical_openai_reasoning_effort(thinking)
|
||||
.and_then(map_openai_reasoning_effort_to_gemini_budget)
|
||||
})?;
|
||||
Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": budget,
|
||||
}))
|
||||
let effort = canonical_openai_reasoning_effort(thinking);
|
||||
gemini_thinking_config_from_reasoning(mapped_model, effort, thinking.budget_tokens)
|
||||
})
|
||||
}) {
|
||||
generation_config.insert("thinkingConfig".to_string(), thinking_config);
|
||||
@@ -421,6 +425,34 @@ fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option
|
||||
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
|
||||
}
|
||||
|
||||
fn gemini_thinking_config_from_reasoning(
|
||||
mapped_model: &str,
|
||||
effort: Option<&str>,
|
||||
budget_tokens: Option<u64>,
|
||||
) -> Option<Value> {
|
||||
if gemini_model_uses_thinking_level(mapped_model) {
|
||||
let level = effort
|
||||
.and_then(ReasoningEffort::parse)
|
||||
.or_else(|| {
|
||||
budget_tokens
|
||||
.map(map_thinking_budget_to_openai_reasoning_effort)
|
||||
.and_then(ReasoningEffort::parse)
|
||||
})
|
||||
.map(ReasoningEffort::as_gemini_level_value)?;
|
||||
return Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": level,
|
||||
}));
|
||||
}
|
||||
|
||||
let budget =
|
||||
budget_tokens.or_else(|| effort.and_then(map_openai_reasoning_effort_to_gemini_budget))?;
|
||||
Some(json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": budget,
|
||||
}))
|
||||
}
|
||||
|
||||
fn apply_response_format_to_gemini_generation_config(
|
||||
generation_config: &mut Map<String, Value>,
|
||||
response_format: &CanonicalResponseFormat,
|
||||
|
||||
@@ -397,7 +397,7 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
|
||||
.and_then(Value::as_str)
|
||||
.map(|effort| {
|
||||
json!({
|
||||
"effort": if effort == "xhigh" { "high" } else { effort },
|
||||
"effort": openai_responses_reasoning_effort(effort),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -410,6 +410,16 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_effort(effort: &str) -> &str {
|
||||
match effort.trim().to_ascii_lowercase().as_str() {
|
||||
"xhigh" | "max" => "xhigh",
|
||||
"low" => "low",
|
||||
"medium" => "medium",
|
||||
"high" => "high",
|
||||
_ => effort,
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
let mut text = Map::new();
|
||||
if let Some(response_format) = &canonical.response_format {
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
pub use crate::request::standard::matrix::build_standard_request_body_from_canonical;
|
||||
pub use crate::request::standard::matrix::{
|
||||
build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_from_canonical_with_model_directives,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod common;
|
||||
pub mod matrix;
|
||||
pub mod model_directives;
|
||||
pub mod openai;
|
||||
pub mod passthrough;
|
||||
pub mod route;
|
||||
|
||||
434
crates/aether-ai-formats/src/request/model_directives.rs
Normal file
434
crates/aether-ai-formats/src/request/model_directives.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelDirective {
|
||||
pub base_model: String,
|
||||
pub overrides: Vec<ModelOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ModelOverride {
|
||||
ReasoningEffort(ReasoningEffort),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReasoningEffort {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(Self::Low),
|
||||
"medium" => Some(Self::Medium),
|
||||
"high" => Some(Self::High),
|
||||
"xhigh" => Some(Self::XHigh),
|
||||
"max" => Some(Self::Max),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_openai_chat_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_openai_responses_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh | Self::Max => "xhigh",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_claude_output_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Max => "max",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_gemini_level_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High | Self::XHigh | Self::Max => "high",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thinking_budget_tokens(self) -> u64 {
|
||||
match self {
|
||||
Self::Low => 1280,
|
||||
Self::Medium => 2048,
|
||||
Self::High => 4096,
|
||||
Self::XHigh | Self::Max => 8192,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_model_directive(model: &str) -> Option<ModelDirective> {
|
||||
let model = model.trim();
|
||||
let (base_model, suffix) = model.rsplit_once('-')?;
|
||||
let base_model = base_model.trim();
|
||||
if base_model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reasoning_effort = ReasoningEffort::parse(suffix)?;
|
||||
Some(ModelDirective {
|
||||
base_model: base_model.to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(reasoning_effort)],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn model_directive_base_model(model: &str) -> Option<String> {
|
||||
parse_model_directive(model).map(|directive| directive.base_model)
|
||||
}
|
||||
|
||||
pub fn normalize_model_directive_model(model: &str) -> String {
|
||||
parse_model_directive(model)
|
||||
.map(|directive| directive.base_model)
|
||||
.unwrap_or_else(|| model.trim().to_string())
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_overrides_from_request(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
request_body: &Value,
|
||||
request_path: Option<&str>,
|
||||
) -> Option<ModelDirective> {
|
||||
let source_model = request_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| request_path.and_then(extract_gemini_model_from_path))?;
|
||||
|
||||
apply_model_directive_overrides_from_model(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
&source_model,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_overrides_from_model(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
source_model: &str,
|
||||
) -> Option<ModelDirective> {
|
||||
let directive = parse_model_directive(source_model)?;
|
||||
for override_item in &directive.overrides {
|
||||
match override_item {
|
||||
ModelOverride::ReasoningEffort(effort) => {
|
||||
apply_reasoning_effort_override(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
*effort,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(directive)
|
||||
}
|
||||
|
||||
pub fn apply_model_directive_mapping_patch(
|
||||
provider_request_body: &mut Value,
|
||||
patch: &Value,
|
||||
) -> Option<()> {
|
||||
deep_merge_json(provider_request_body, patch);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn deep_merge_json(target: &mut Value, patch: &Value) {
|
||||
match (target, patch) {
|
||||
(Value::Object(target_object), Value::Object(patch_object)) => {
|
||||
for (key, patch_value) in patch_object {
|
||||
match target_object.get_mut(key) {
|
||||
Some(target_value) => deep_merge_json(target_value, patch_value),
|
||||
None => {
|
||||
target_object.insert(key.clone(), patch_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, patch) => {
|
||||
*target = patch.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_reasoning_effort_override(
|
||||
provider_request_body: &mut Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
effort: ReasoningEffort,
|
||||
) -> Option<()> {
|
||||
match crate::normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:chat" => set_object_string(
|
||||
provider_request_body,
|
||||
"reasoning_effort",
|
||||
effort.as_openai_chat_value(),
|
||||
),
|
||||
"openai:responses" | "openai:responses:compact" => {
|
||||
set_openai_responses_reasoning_effort(provider_request_body, effort)
|
||||
}
|
||||
"claude:messages" => {
|
||||
set_claude_reasoning_effort(provider_request_body, effort, provider_model)
|
||||
}
|
||||
"gemini:generate_content" => {
|
||||
set_gemini_reasoning_effort(provider_request_body, effort, provider_model)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_object_string(body: &mut Value, key: &str, value: &str) -> Option<()> {
|
||||
body.as_object_mut()?
|
||||
.insert(key.to_string(), Value::String(value.to_string()));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_openai_responses_reasoning_effort(body: &mut Value, effort: ReasoningEffort) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let reasoning = body_object
|
||||
.entry("reasoning".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !reasoning.is_object() {
|
||||
*reasoning = json!({});
|
||||
}
|
||||
reasoning.as_object_mut()?.insert(
|
||||
"effort".to_string(),
|
||||
Value::String(effort.as_openai_responses_value().to_string()),
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_claude_reasoning_effort(
|
||||
body: &mut Value,
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let output_config = body_object
|
||||
.entry("output_config".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !output_config.is_object() {
|
||||
*output_config = json!({});
|
||||
}
|
||||
output_config.as_object_mut()?.insert(
|
||||
"effort".to_string(),
|
||||
Value::String(effort.as_claude_output_value().to_string()),
|
||||
);
|
||||
|
||||
let thinking = body_object
|
||||
.entry("thinking".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !thinking.is_object() {
|
||||
*thinking = json!({});
|
||||
}
|
||||
let thinking = thinking.as_object_mut()?;
|
||||
if claude_model_uses_adaptive_effort(provider_model) {
|
||||
thinking.insert("type".to_string(), Value::String("adaptive".to_string()));
|
||||
thinking.remove("budget_tokens");
|
||||
} else {
|
||||
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
|
||||
thinking.insert(
|
||||
"budget_tokens".to_string(),
|
||||
Value::from(effort.thinking_budget_tokens()),
|
||||
);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn set_gemini_reasoning_effort(
|
||||
body: &mut Value,
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
) -> Option<()> {
|
||||
let body_object = body.as_object_mut()?;
|
||||
let generation_key = if body_object.contains_key("generation_config")
|
||||
&& !body_object.contains_key("generationConfig")
|
||||
{
|
||||
"generation_config"
|
||||
} else {
|
||||
"generationConfig"
|
||||
};
|
||||
let generation_config = body_object
|
||||
.entry(generation_key.to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
if !generation_config.is_object() {
|
||||
*generation_config = json!({});
|
||||
}
|
||||
let generation_config = generation_config.as_object_mut()?;
|
||||
let thinking_key = if generation_config.contains_key("thinking_config")
|
||||
&& !generation_config.contains_key("thinkingConfig")
|
||||
{
|
||||
"thinking_config"
|
||||
} else {
|
||||
"thinkingConfig"
|
||||
};
|
||||
generation_config.insert(
|
||||
thinking_key.to_string(),
|
||||
gemini_reasoning_effort_config(effort, provider_model, thinking_key),
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn gemini_reasoning_effort_config(
|
||||
effort: ReasoningEffort,
|
||||
provider_model: &str,
|
||||
thinking_key: &str,
|
||||
) -> Value {
|
||||
if gemini_model_uses_thinking_level(provider_model) {
|
||||
if thinking_key == "thinking_config" {
|
||||
return json!({
|
||||
"include_thoughts": true,
|
||||
"thinking_level": effort.as_gemini_level_value(),
|
||||
});
|
||||
}
|
||||
return json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingLevel": effort.as_gemini_level_value(),
|
||||
});
|
||||
}
|
||||
|
||||
if thinking_key == "thinking_config" {
|
||||
return json!({
|
||||
"include_thoughts": true,
|
||||
"thinking_budget": effort.thinking_budget_tokens(),
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"includeThoughts": true,
|
||||
"thinkingBudget": effort.thinking_budget_tokens(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
|
||||
let model = model.trim().to_ascii_lowercase().replace(['.', '_'], "-");
|
||||
model.contains("mythos")
|
||||
|| model.contains("opus-4-7")
|
||||
|| model.contains("opus-4-6")
|
||||
|| model.contains("sonnet-4-6")
|
||||
}
|
||||
|
||||
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
|
||||
model
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.split('/')
|
||||
.any(|part| part.starts_with("gemini-3"))
|
||||
}
|
||||
|
||||
pub fn extract_gemini_model_from_path(path: &str) -> Option<String> {
|
||||
let marker = "/models/";
|
||||
let start = path.find(marker)? + marker.len();
|
||||
let tail = &path[start..];
|
||||
let end = tail.find(':').unwrap_or(tail.len());
|
||||
let model = tail[..end].trim();
|
||||
(!model.is_empty()).then(|| model.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
apply_model_directive_overrides_from_model, parse_model_directive, ModelDirective,
|
||||
ModelOverride, ReasoningEffort,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_supported_reasoning_effort_suffixes() {
|
||||
assert_eq!(
|
||||
parse_model_directive("gpt-5.4-xhigh"),
|
||||
Some(ModelDirective {
|
||||
base_model: "gpt-5.4".to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::XHigh)],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_model_directive("gpt-5.4-MAX"),
|
||||
Some(ModelDirective {
|
||||
base_model: "gpt-5.4".to_string(),
|
||||
overrides: vec![ModelOverride::ReasoningEffort(ReasoningEffort::Max)],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_or_incomplete_suffixes() {
|
||||
assert_eq!(parse_model_directive("gpt-5.4-ultra"), None);
|
||||
assert_eq!(parse_model_directive("gpt-5.4"), None);
|
||||
assert_eq!(parse_model_directive("-high"), None);
|
||||
assert_eq!(parse_model_directive("gpt-5.4-high-json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_reasoning_effort_to_provider_body_shapes() {
|
||||
let mut openai_chat = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut openai_chat,
|
||||
"openai:chat",
|
||||
"gpt-5-upstream",
|
||||
"gpt-5.4-xhigh",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
|
||||
|
||||
let mut responses = json!({
|
||||
"model": "gpt-5-upstream",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut responses,
|
||||
"openai:responses",
|
||||
"gpt-5-upstream",
|
||||
"gpt-5.4-max",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(responses["reasoning"]["effort"], "xhigh");
|
||||
assert_eq!(responses["reasoning"]["summary"], "auto");
|
||||
|
||||
let mut claude = json!({"model": "claude-sonnet-4-5"});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut claude,
|
||||
"claude:messages",
|
||||
"claude-sonnet-4-5",
|
||||
"gpt-5.4-high",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(claude["thinking"]["budget_tokens"], 4096);
|
||||
|
||||
let mut gemini = json!({});
|
||||
apply_model_directive_overrides_from_model(
|
||||
&mut gemini,
|
||||
"gemini:generate_content",
|
||||
"gemini-2.5-pro",
|
||||
"gpt-5.4-medium",
|
||||
)
|
||||
.expect("directive should apply");
|
||||
assert_eq!(
|
||||
gemini["generationConfig"]["thinkingConfig"]["thinkingBudget"],
|
||||
2048
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::model_directives::ReasoningEffort;
|
||||
|
||||
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
|
||||
match stop {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => {
|
||||
@@ -55,23 +57,11 @@ pub fn copy_request_number_field_as(
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_claude_output(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::as_claude_output_value)
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_thinking_budget(value: &str) -> Option<u64> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some(1280),
|
||||
"medium" => Some(2048),
|
||||
"high" => Some(4096),
|
||||
"xhigh" => Some(8192),
|
||||
_ => None,
|
||||
}
|
||||
ReasoningEffort::parse(value).map(ReasoningEffort::thinking_budget_tokens)
|
||||
}
|
||||
|
||||
pub fn map_openai_reasoning_effort_to_gemini_budget(value: &str) -> Option<u64> {
|
||||
|
||||
@@ -11,10 +11,12 @@ use aether_ai_formats::protocol::registry::{convert_request, FormatContext};
|
||||
use aether_ai_formats::provider_compat::proxy::rules::apply_local_body_rules;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::request::model_directives::apply_model_directive_overrides_from_request;
|
||||
|
||||
use super::{
|
||||
apply_openai_responses_compact_special_body_edits,
|
||||
codex::apply_codex_openai_responses_special_body_edits,
|
||||
normalize::build_local_openai_chat_request_body,
|
||||
normalize::build_local_openai_chat_request_body_with_model_directives,
|
||||
};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -28,6 +30,33 @@ pub fn build_standard_request_body(
|
||||
upstream_is_stream: bool,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
build_standard_request_body_with_model_directives(
|
||||
body_json,
|
||||
client_api_format,
|
||||
mapped_model,
|
||||
provider_type,
|
||||
provider_api_format,
|
||||
request_path,
|
||||
upstream_is_stream,
|
||||
body_rules,
|
||||
user_api_key_id,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_standard_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
mapped_model: &str,
|
||||
provider_type: &str,
|
||||
provider_api_format: &str,
|
||||
request_path: &str,
|
||||
upstream_is_stream: bool,
|
||||
body_rules: Option<&Value>,
|
||||
user_api_key_id: Option<&str>,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let format_context = FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
@@ -41,6 +70,16 @@ pub fn build_standard_request_body(
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if enable_model_directives {
|
||||
apply_model_directive_overrides_from_request(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
Some(request_path),
|
||||
);
|
||||
}
|
||||
|
||||
if !apply_local_body_rules(&mut provider_request_body, body_rules, Some(body_json)) {
|
||||
return None;
|
||||
}
|
||||
@@ -64,11 +103,29 @@ pub fn build_standard_request_body_from_canonical(
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_standard_request_body_from_canonical_with_model_directives(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_standard_request_body_from_canonical_with_model_directives(
|
||||
canonical_request: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let mut provider_request_body =
|
||||
match aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str() {
|
||||
"openai:chat" => build_local_openai_chat_request_body(
|
||||
"openai:chat" => build_local_openai_chat_request_body_with_model_directives(
|
||||
canonical_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
enable_model_directives,
|
||||
),
|
||||
"openai:responses" => convert_openai_chat_request_to_openai_responses_request(
|
||||
canonical_request,
|
||||
@@ -93,7 +150,17 @@ pub fn build_standard_request_body_from_canonical(
|
||||
upstream_is_stream,
|
||||
),
|
||||
_ => None,
|
||||
}?;
|
||||
if enable_model_directives {
|
||||
apply_model_directive_overrides_from_request(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
canonical_request,
|
||||
None,
|
||||
);
|
||||
}
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
pub fn normalize_standard_request_to_openai_chat_request(
|
||||
@@ -133,6 +200,7 @@ fn normalize_standard_request_to_openai_chat_request_cow<'a>(
|
||||
mod tests {
|
||||
use super::{
|
||||
build_standard_request_body, build_standard_request_body_from_canonical,
|
||||
build_standard_request_body_with_model_directives,
|
||||
normalize_standard_request_to_openai_chat_request,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
@@ -425,6 +493,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_request_body_applies_reasoning_effort_suffix_to_claude_target() {
|
||||
let request = json!({
|
||||
"model": "gpt-5.4-max",
|
||||
"messages": [{"role": "user", "content": "Need high effort"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body_with_model_directives(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"claude-sonnet-4-5",
|
||||
"anthropic",
|
||||
"claude:messages",
|
||||
"/v1/chat/completions",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("openai chat should convert to claude chat");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["output_config"]["effort"], "max");
|
||||
assert_eq!(converted["thinking"]["budget_tokens"], 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_request_body_applies_reasoning_effort_suffix_from_gemini_path() {
|
||||
let request = json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{"text": "Need high effort"}]
|
||||
}]
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body_with_model_directives(
|
||||
&request,
|
||||
"gemini:generate_content",
|
||||
"gpt-5.4",
|
||||
"openai",
|
||||
"openai:chat",
|
||||
"/v1beta/models/gemini-2.5-pro-high:generateContent",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("gemini should convert to openai chat");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-5.4");
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_request_uses_typed_canonical_without_changing_target_payloads() {
|
||||
let request = json!({
|
||||
|
||||
@@ -13,8 +13,18 @@ pub use codex::{
|
||||
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
|
||||
};
|
||||
pub use family::{LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec};
|
||||
pub use matrix::{build_standard_request_body, normalize_standard_request_to_openai_chat_request};
|
||||
pub use normalize::{
|
||||
build_cross_format_openai_chat_request_body, build_cross_format_openai_responses_request_body,
|
||||
build_local_openai_chat_request_body, build_local_openai_responses_request_body,
|
||||
pub use matrix::{
|
||||
build_standard_request_body, build_standard_request_body_from_canonical_with_model_directives,
|
||||
build_standard_request_body_with_model_directives,
|
||||
normalize_standard_request_to_openai_chat_request,
|
||||
};
|
||||
pub use normalize::{
|
||||
build_cross_format_openai_chat_request_body,
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body,
|
||||
build_cross_format_openai_responses_request_body_with_model_directives,
|
||||
build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
};
|
||||
|
||||
@@ -6,10 +6,26 @@ use aether_ai_formats::protocol::conversion::request::{
|
||||
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::request::model_directives::apply_model_directive_overrides_from_request;
|
||||
|
||||
pub fn build_local_openai_chat_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
@@ -34,7 +50,14 @@ pub fn build_local_openai_chat_request_body(
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Value::Object(provider_request_body))
|
||||
Some(with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:chat",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body(
|
||||
@@ -42,35 +65,73 @@ pub fn build_cross_format_openai_chat_request_body(
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
|
||||
match conversion_kind {
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
)?,
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
)?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
) -> Option<Value> {
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
require_streaming,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_local_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
require_streaming: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let request_body_object = body_json.as_object()?;
|
||||
let mut provider_request_body = serde_json::Map::from_iter(
|
||||
@@ -82,7 +143,14 @@ pub fn build_local_openai_responses_request_body(
|
||||
if require_streaming {
|
||||
provider_request_body.insert("stream".to_string(), Value::Bool(true));
|
||||
}
|
||||
Some(Value::Object(provider_request_body))
|
||||
Some(with_model_directive_overrides(
|
||||
Value::Object(provider_request_body),
|
||||
"openai:responses",
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_responses_request_body(
|
||||
@@ -91,41 +159,93 @@ pub fn build_cross_format_openai_responses_request_body(
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json,
|
||||
mapped_model,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_cross_format_openai_responses_request_body_with_model_directives(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
upstream_is_stream: bool,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<Value> {
|
||||
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
|
||||
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
|
||||
match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => build_local_openai_chat_request_body(
|
||||
let provider_request_body = match conversion_kind {
|
||||
RequestConversionKind::ToOpenAIChat => {
|
||||
build_local_openai_chat_request_body_with_model_directives(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
enable_model_directives,
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToOpenAiResponses => {
|
||||
convert_openai_chat_request_to_openai_responses_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
false,
|
||||
)
|
||||
)?
|
||||
}
|
||||
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
)?,
|
||||
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
|
||||
&chat_like_request,
|
||||
mapped_model,
|
||||
upstream_is_stream,
|
||||
),
|
||||
)?,
|
||||
};
|
||||
Some(with_model_directive_overrides(
|
||||
provider_request_body,
|
||||
provider_api_format,
|
||||
mapped_model,
|
||||
body_json,
|
||||
None,
|
||||
enable_model_directives,
|
||||
))
|
||||
}
|
||||
|
||||
fn with_model_directive_overrides(
|
||||
mut provider_request_body: Value,
|
||||
provider_api_format: &str,
|
||||
provider_model: &str,
|
||||
request_body: &Value,
|
||||
request_path: Option<&str>,
|
||||
enable_model_directives: bool,
|
||||
) -> Value {
|
||||
if enable_model_directives {
|
||||
apply_model_directive_overrides_from_request(
|
||||
&mut provider_request_body,
|
||||
provider_api_format,
|
||||
provider_model,
|
||||
request_body,
|
||||
request_path,
|
||||
);
|
||||
}
|
||||
provider_request_body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_local_openai_responses_request_body;
|
||||
use super::{
|
||||
build_cross_format_openai_chat_request_body_with_model_directives,
|
||||
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -204,6 +324,87 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_chat_request_body_leaves_model_directive_disabled_by_default() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-xhigh",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", false)
|
||||
.expect("openai chat body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-max",
|
||||
"input": "hello",
|
||||
"reasoning": {"effort": "low", "summary": "auto"}
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_local_openai_responses_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("openai responses body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
|
||||
assert_eq!(provider_request_body["reasoning"]["summary"], "auto");
|
||||
assert_eq!(provider_request_body["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_format_request_body_applies_reasoning_effort_suffix() {
|
||||
let body_json = json!({
|
||||
"model": "gpt-5.4-high",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
});
|
||||
|
||||
let provider_request_body =
|
||||
build_cross_format_openai_chat_request_body_with_model_directives(
|
||||
&body_json,
|
||||
"claude-sonnet-4-5",
|
||||
"claude:messages",
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.expect("claude body should build");
|
||||
|
||||
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(provider_request_body["output_config"]["effort"], "high");
|
||||
assert_eq!(provider_request_body["thinking"]["budget_tokens"], 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_local_openai_chat_request_body_preserves_stream_options_while_forcing_include_usage(
|
||||
) {
|
||||
|
||||
@@ -52,11 +52,14 @@ pub struct SameFormatProviderRequestBehavior {
|
||||
pub struct SameFormatProviderRequestBodyInput<'a> {
|
||||
pub body_json: &'a Value,
|
||||
pub mapped_model: &'a str,
|
||||
pub provider_api_format: &'a str,
|
||||
pub source_model: Option<&'a str>,
|
||||
pub family: SameFormatProviderFamily,
|
||||
pub body_rules: Option<&'a Value>,
|
||||
pub upstream_is_stream: bool,
|
||||
pub kiro_auth_config: Option<&'a KiroAuthConfig>,
|
||||
pub is_claude_code: bool,
|
||||
pub enable_model_directives: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -154,6 +157,16 @@ pub fn build_same_format_provider_request_body(
|
||||
if input.is_claude_code {
|
||||
crate::claude_code::sanitize_claude_code_request_body(&mut provider_request_body);
|
||||
}
|
||||
if input.enable_model_directives {
|
||||
if let Some(source_model) = input.source_model {
|
||||
aether_ai_formats::apply_model_directive_overrides_from_model(
|
||||
&mut provider_request_body,
|
||||
input.provider_api_format,
|
||||
input.mapped_model,
|
||||
source_model,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !apply_local_body_rules(
|
||||
&mut provider_request_body,
|
||||
input.body_rules,
|
||||
@@ -475,11 +488,14 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}),
|
||||
mapped_model: "upstream-model",
|
||||
provider_api_format: "openai:chat",
|
||||
source_model: Some("client-model"),
|
||||
family: SameFormatProviderFamily::Standard,
|
||||
body_rules: None,
|
||||
upstream_is_stream: true,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: false,
|
||||
})
|
||||
.expect("body should build");
|
||||
|
||||
@@ -487,6 +503,33 @@ mod tests {
|
||||
assert_eq!(body.get("stream"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_format_body_applies_model_directive_before_body_rules() {
|
||||
let body = build_same_format_provider_request_body(SameFormatProviderRequestBodyInput {
|
||||
body_json: &json!({
|
||||
"model": "gpt-5.4-high",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"reasoning_effort": "low"
|
||||
}),
|
||||
mapped_model: "upstream-model",
|
||||
provider_api_format: "openai:chat",
|
||||
source_model: Some("gpt-5.4-high"),
|
||||
family: SameFormatProviderFamily::Standard,
|
||||
body_rules: Some(&json!([
|
||||
{"action":"set","path":"metadata.body_rule_seen","value":true}
|
||||
])),
|
||||
upstream_is_stream: false,
|
||||
kiro_auth_config: None,
|
||||
is_claude_code: false,
|
||||
enable_model_directives: true,
|
||||
})
|
||||
.expect("body should build");
|
||||
|
||||
assert_eq!(body["model"], "upstream-model");
|
||||
assert_eq!(body["reasoning_effort"], "high");
|
||||
assert_eq!(body["metadata"]["body_rule_seen"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_same_format_headers_with_auth_and_stream_accept() {
|
||||
let provider_request_body = json!({"model": "upstream-model"});
|
||||
|
||||
@@ -63,23 +63,44 @@ pub fn auth_constraints_allow_model(
|
||||
constraints: Option<&SchedulerAuthConstraints>,
|
||||
requested_model_name: &str,
|
||||
resolved_global_model_name: &str,
|
||||
) -> bool {
|
||||
auth_constraints_allow_model_with_model_directives(
|
||||
constraints,
|
||||
requested_model_name,
|
||||
resolved_global_model_name,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn auth_constraints_allow_model_with_model_directives(
|
||||
constraints: Option<&SchedulerAuthConstraints>,
|
||||
requested_model_name: &str,
|
||||
resolved_global_model_name: &str,
|
||||
enable_model_directives: bool,
|
||||
) -> bool {
|
||||
let Some(allowed) = constraints.and_then(|constraints| constraints.allowed_models.as_deref())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
allowed
|
||||
.iter()
|
||||
.any(|value| value == requested_model_name || value == resolved_global_model_name)
|
||||
let base_model = enable_model_directives
|
||||
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
|
||||
.flatten();
|
||||
allowed.iter().any(|value| {
|
||||
value == requested_model_name
|
||||
|| value == resolved_global_model_name
|
||||
|| base_model
|
||||
.as_ref()
|
||||
.is_some_and(|base_model| value == base_model)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_format_matches_allowed_value, auth_constraints_allow_api_format,
|
||||
auth_constraints_allow_model, auth_constraints_allow_provider,
|
||||
provider_matches_allowed_value, SchedulerAuthConstraints,
|
||||
auth_constraints_allow_model, auth_constraints_allow_model_with_model_directives,
|
||||
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
|
||||
};
|
||||
|
||||
fn sample_constraints() -> SchedulerAuthConstraints {
|
||||
@@ -200,6 +221,23 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_directive_base_model_requires_explicit_enablement() {
|
||||
let constraints = sample_constraints();
|
||||
|
||||
assert!(!auth_constraints_allow_model(
|
||||
Some(&constraints),
|
||||
"gpt-5-high",
|
||||
"gpt-5-high"
|
||||
));
|
||||
assert!(auth_constraints_allow_model_with_model_directives(
|
||||
Some(&constraints),
|
||||
"gpt-5-high",
|
||||
"gpt-5-high",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_format_allowed_value_matches_current_signatures_only() {
|
||||
assert!(api_format_matches_allowed_value(
|
||||
|
||||
@@ -9,6 +9,20 @@ use super::types::{
|
||||
|
||||
pub fn enumerate_minimal_candidate_selection(
|
||||
input: EnumerateMinimalCandidateSelectionInput<'_>,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
|
||||
enumerate_minimal_candidate_selection_inner(input, false)
|
||||
}
|
||||
|
||||
pub fn enumerate_minimal_candidate_selection_with_model_directives(
|
||||
input: EnumerateMinimalCandidateSelectionInput<'_>,
|
||||
enable_model_directives: bool,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
|
||||
enumerate_minimal_candidate_selection_inner(input, enable_model_directives)
|
||||
}
|
||||
|
||||
fn enumerate_minimal_candidate_selection_inner(
|
||||
input: EnumerateMinimalCandidateSelectionInput<'_>,
|
||||
enable_model_directives: bool,
|
||||
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, DataLayerError> {
|
||||
let EnumerateMinimalCandidateSelectionInput {
|
||||
rows,
|
||||
@@ -26,10 +40,11 @@ pub fn enumerate_minimal_candidate_selection(
|
||||
if !crate::auth_constraints_allow_api_format(auth_constraints, normalized_api_format) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !crate::auth_constraints_allow_model(
|
||||
if !crate::auth_constraints_allow_model_with_model_directives(
|
||||
auth_constraints,
|
||||
requested_model_name,
|
||||
resolved_global_model_name,
|
||||
enable_model_directives,
|
||||
) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -48,7 +63,12 @@ pub fn enumerate_minimal_candidate_selection(
|
||||
continue;
|
||||
}
|
||||
let Some((selected_provider_model_name, mapping_matched_model)) =
|
||||
crate::resolve_provider_model_name(&row, requested_model_name, normalized_api_format)
|
||||
crate::resolve_provider_model_name_with_model_directives(
|
||||
&row,
|
||||
requested_model_name,
|
||||
normalized_api_format,
|
||||
enable_model_directives,
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ pub use capability::{
|
||||
};
|
||||
pub use enumeration::{
|
||||
collect_global_model_names_for_required_capability, enumerate_minimal_candidate_selection,
|
||||
enumerate_minimal_candidate_selection_with_model_directives,
|
||||
};
|
||||
pub use selectability::{
|
||||
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,
|
||||
|
||||
@@ -13,13 +13,14 @@ pub use affinity::{
|
||||
};
|
||||
pub use auth::{
|
||||
api_format_matches_allowed_value, auth_constraints_allow_api_format,
|
||||
auth_constraints_allow_model, auth_constraints_allow_provider, provider_matches_allowed_value,
|
||||
SchedulerAuthConstraints,
|
||||
auth_constraints_allow_model, auth_constraints_allow_model_with_model_directives,
|
||||
auth_constraints_allow_provider, provider_matches_allowed_value, SchedulerAuthConstraints,
|
||||
};
|
||||
pub use candidate::{
|
||||
auth_api_key_concurrency_limit_reached, candidate_is_selectable_with_runtime_state,
|
||||
candidate_runtime_skip_reason_with_state, candidate_supports_required_capability,
|
||||
collect_global_model_names_for_required_capability, enumerate_minimal_candidate_selection,
|
||||
enumerate_minimal_candidate_selection_with_model_directives,
|
||||
requested_capability_priority_for_candidate, CandidateRuntimeSelectabilityInput,
|
||||
EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate,
|
||||
SchedulerPriorityMode,
|
||||
@@ -35,8 +36,11 @@ pub use health::{
|
||||
};
|
||||
pub use model::{
|
||||
candidate_model_names, extract_global_priority_for_format, matches_model_mapping,
|
||||
normalize_api_format, resolve_provider_model_name, resolve_requested_global_model_name,
|
||||
row_supports_requested_model, row_supports_required_capability, select_provider_model_name,
|
||||
normalize_api_format, resolve_provider_model_name,
|
||||
resolve_provider_model_name_with_model_directives, resolve_requested_global_model_name,
|
||||
resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
|
||||
row_supports_requested_model_with_model_directives, row_supports_required_capability,
|
||||
select_provider_model_name,
|
||||
};
|
||||
pub use provider::{build_provider_concurrent_limit_map, should_skip_provider_quota};
|
||||
pub use ranking::{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
@@ -11,6 +12,23 @@ pub fn resolve_requested_global_model_name(
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
) -> Option<String> {
|
||||
resolve_requested_global_model_name_with_model_directives(
|
||||
rows,
|
||||
requested_model_name,
|
||||
api_format,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_requested_global_model_name_with_model_directives(
|
||||
rows: &[StoredMinimalCandidateSelectionRow],
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<String> {
|
||||
requested_model_name_candidates(requested_model_name, enable_model_directives).find_map(
|
||||
|requested_model_name| {
|
||||
let requested_model_name = requested_model_name.as_ref();
|
||||
resolve_global_model_name_by(rows, |row| row.global_model_name == requested_model_name)
|
||||
.or_else(|| {
|
||||
resolve_global_model_name_by(rows, |row| {
|
||||
@@ -38,12 +56,35 @@ pub fn resolve_requested_global_model_name(
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn row_supports_requested_model(
|
||||
row: &StoredMinimalCandidateSelectionRow,
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
row_supports_requested_model_with_model_directives(row, requested_model_name, api_format, false)
|
||||
}
|
||||
|
||||
pub fn row_supports_requested_model_with_model_directives(
|
||||
row: &StoredMinimalCandidateSelectionRow,
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
enable_model_directives: bool,
|
||||
) -> bool {
|
||||
requested_model_name_candidates(requested_model_name, enable_model_directives).any(
|
||||
|requested_model_name| {
|
||||
row_supports_requested_model_exact(row, requested_model_name.as_ref(), api_format)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn row_supports_requested_model_exact(
|
||||
row: &StoredMinimalCandidateSelectionRow,
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
row.global_model_name == requested_model_name
|
||||
|| row.model_provider_model_name == requested_model_name
|
||||
@@ -87,6 +128,15 @@ pub fn resolve_provider_model_name(
|
||||
row: &StoredMinimalCandidateSelectionRow,
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
resolve_provider_model_name_with_model_directives(row, requested_model_name, api_format, false)
|
||||
}
|
||||
|
||||
pub fn resolve_provider_model_name_with_model_directives(
|
||||
row: &StoredMinimalCandidateSelectionRow,
|
||||
requested_model_name: &str,
|
||||
api_format: &str,
|
||||
enable_model_directives: bool,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let selected_provider_model_name = select_provider_model_name(row, api_format);
|
||||
let Some(key_allowed_models) = row.key_allowed_models.as_ref() else {
|
||||
@@ -103,6 +153,16 @@ pub fn resolve_provider_model_name(
|
||||
return Some((selected_provider_model_name, None));
|
||||
}
|
||||
|
||||
if enable_model_directives {
|
||||
if let Some(base_model) =
|
||||
aether_ai_formats::model_directive_base_model(requested_model_name)
|
||||
{
|
||||
if key_allowed_models.iter().any(|value| value == &base_model) {
|
||||
return Some((selected_provider_model_name, Some(base_model)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut sorted_allowed_models = key_allowed_models
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
@@ -302,9 +362,26 @@ fn api_format_matches(left: &str, right: &str) -> bool {
|
||||
normalize_api_format(left) == normalize_api_format(right)
|
||||
}
|
||||
|
||||
fn requested_model_name_candidates(
|
||||
requested_model_name: &str,
|
||||
enable_model_directives: bool,
|
||||
) -> impl Iterator<Item = Cow<'_, str>> {
|
||||
let requested_model_name = requested_model_name.trim();
|
||||
let base_model = enable_model_directives
|
||||
.then(|| aether_ai_formats::model_directive_base_model(requested_model_name))
|
||||
.flatten();
|
||||
std::iter::once(Cow::Borrowed(requested_model_name)).chain(base_model.map(Cow::Owned))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::matches_model_mapping;
|
||||
use super::{
|
||||
matches_model_mapping, resolve_provider_model_name,
|
||||
resolve_provider_model_name_with_model_directives,
|
||||
resolve_requested_global_model_name_with_model_directives, row_supports_requested_model,
|
||||
row_supports_requested_model_with_model_directives,
|
||||
};
|
||||
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
|
||||
|
||||
#[test]
|
||||
fn model_mapping_match_is_case_insensitive() {
|
||||
@@ -322,4 +399,103 @@ mod tests {
|
||||
fn invalid_model_mapping_pattern_returns_false() {
|
||||
assert!(!matches_model_mapping("([a-z", "gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_directive_suffix_matches_base_model_as_fallback() {
|
||||
let row = sample_row("gpt-5.4", "gpt-5.4-upstream");
|
||||
|
||||
assert!(!row_supports_requested_model(
|
||||
&row,
|
||||
"gpt-5.4-xhigh",
|
||||
"openai:chat"
|
||||
));
|
||||
assert!(row_supports_requested_model_with_model_directives(
|
||||
&row,
|
||||
"gpt-5.4-xhigh",
|
||||
"openai:chat",
|
||||
true
|
||||
));
|
||||
assert_eq!(
|
||||
resolve_requested_global_model_name_with_model_directives(
|
||||
&[row],
|
||||
"gpt-5.4-xhigh",
|
||||
"openai:chat",
|
||||
true
|
||||
)
|
||||
.as_deref(),
|
||||
Some("gpt-5.4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_directive_suffix_prefers_exact_model_before_base_fallback() {
|
||||
let exact = sample_row("gpt-5.4-high", "gpt-5.4-high-upstream");
|
||||
let base = sample_row("gpt-5.4", "gpt-5.4-upstream");
|
||||
|
||||
assert_eq!(
|
||||
resolve_requested_global_model_name_with_model_directives(
|
||||
&[base, exact],
|
||||
"gpt-5.4-high",
|
||||
"openai:chat",
|
||||
true
|
||||
)
|
||||
.as_deref(),
|
||||
Some("gpt-5.4-high")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_directive_base_model_satisfies_key_allowed_models() {
|
||||
let mut row = sample_row("gpt-5.4", "gpt-5.4-upstream");
|
||||
row.key_allowed_models = Some(vec!["gpt-5.4".to_string()]);
|
||||
|
||||
assert!(resolve_provider_model_name(&row, "gpt-5.4-max", "openai:chat").is_none());
|
||||
let resolved = resolve_provider_model_name_with_model_directives(
|
||||
&row,
|
||||
"gpt-5.4-max",
|
||||
"openai:chat",
|
||||
true,
|
||||
)
|
||||
.expect("base model should satisfy key allowed models");
|
||||
|
||||
assert_eq!(resolved.0, "gpt-5.4-upstream");
|
||||
assert_eq!(resolved.1.as_deref(), Some("gpt-5.4"));
|
||||
}
|
||||
|
||||
fn sample_row(
|
||||
global_model_name: &str,
|
||||
model_provider_model_name: &str,
|
||||
) -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "Provider".to_string(),
|
||||
provider_type: "openai".to_string(),
|
||||
provider_priority: 0,
|
||||
provider_is_active: true,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
endpoint_api_family: None,
|
||||
endpoint_kind: None,
|
||||
endpoint_is_active: true,
|
||||
key_id: "key-1".to_string(),
|
||||
key_name: "Key".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_is_active: true,
|
||||
key_api_formats: None,
|
||||
key_allowed_models: None,
|
||||
key_capabilities: None,
|
||||
key_internal_priority: 0,
|
||||
key_global_priority_by_format: None,
|
||||
model_id: format!("model-{global_model_name}"),
|
||||
global_model_id: format!("global-{global_model_name}"),
|
||||
global_model_name: global_model_name.to_string(),
|
||||
global_model_mappings: None,
|
||||
global_model_supports_streaming: Some(true),
|
||||
model_provider_model_name: model_provider_model_name.to_string(),
|
||||
model_provider_model_mappings: None,
|
||||
model_supports_streaming: Some(true),
|
||||
model_is_active: true,
|
||||
model_is_available: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +381,7 @@ import {
|
||||
Zap,
|
||||
FileUp,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
@@ -559,6 +560,7 @@ const navigation = computed(() => {
|
||||
Shield,
|
||||
Puzzle,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
}
|
||||
|
||||
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)
|
||||
|
||||
@@ -220,6 +220,12 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ModuleManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModuleManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'model-directives',
|
||||
name: 'ModelDirectivesManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ModelDirectivesManagement.vue')),
|
||||
meta: { module: 'model_directives' }
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
|
||||
97
frontend/src/views/admin/ModelDirectivesManagement.vue
Normal file
97
frontend/src/views/admin/ModelDirectivesManagement.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="模型后缀参数"
|
||||
description="允许通过模型名后缀覆盖推理参数"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="loadConfig"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4 mr-2"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-6 space-y-5">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-6"
|
||||
>
|
||||
<ModelDirectivesPanel
|
||||
:config="modelDirectivesConfig"
|
||||
:loading="loading || saving"
|
||||
@save="saveConfig"
|
||||
@update:config="modelDirectivesConfig = $event"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { PageContainer, PageHeader } from '@/components/layout'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getErrorMessage } from '@/types/api-error'
|
||||
import ModelDirectivesPanel from './module-management/ModelDirectivesPanel.vue'
|
||||
import {
|
||||
createDefaultModelDirectivesConfig,
|
||||
normalizeModelDirectivesConfig,
|
||||
type ModelDirectivesConfig,
|
||||
} from './module-management/modelDirectivesConfig'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
const modelDirectivesConfig = ref<ModelDirectivesConfig>(createDefaultModelDirectivesConfig())
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig('model_directives')
|
||||
const normalized = normalizeModelDirectivesConfig(response.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
} catch (err) {
|
||||
error('获取模型后缀参数配置失败')
|
||||
log.error('获取模型后缀参数配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const normalized = normalizeModelDirectivesConfig(modelDirectivesConfig.value)
|
||||
modelDirectivesConfig.value = normalized
|
||||
await adminApi.updateSystemConfig(
|
||||
'model_directives',
|
||||
normalized,
|
||||
'模型后缀参数配置'
|
||||
)
|
||||
success('模型后缀参数配置已保存')
|
||||
} catch (err) {
|
||||
error(getErrorMessage(err, '保存模型后缀参数配置失败'))
|
||||
log.error('保存模型后缀参数配置失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">
|
||||
推理参数
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
各端点可以分别启用推理参数,并配置推理程度到实际请求参数和值的映射。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Switch
|
||||
:model-value="config.reasoning_effort.enabled"
|
||||
:disabled="loading"
|
||||
@update:model-value="onReasoningEnabledChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border">
|
||||
<div class="grid grid-cols-1 gap-2 border-b bg-muted/40 px-4 py-3 text-xs font-medium text-muted-foreground lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]">
|
||||
<div>API 端点</div>
|
||||
<div>推理程度</div>
|
||||
<div>映射参数</div>
|
||||
<div class="md:text-right">状态</div>
|
||||
</div>
|
||||
<div class="divide-y">
|
||||
<div
|
||||
v-for="format in MODEL_DIRECTIVE_API_FORMATS"
|
||||
:key="format.key"
|
||||
class="grid grid-cols-1 items-center gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.8fr)_auto]"
|
||||
>
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ format.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="selectedEfforts[format.key] ?? 'low'"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
@update:model-value="value => selectEffort(format.key, value)"
|
||||
>
|
||||
<SelectTrigger class="h-9 w-28 rounded-lg">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="effort in DEFAULT_REASONING_SUFFIXES"
|
||||
:key="effort"
|
||||
:value="effort"
|
||||
>
|
||||
{{ effort }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="localMappingParams[mappingKey(format.key)]"
|
||||
class="h-9 font-mono text-xs"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled"
|
||||
placeholder="{"reasoning_effort":"low"}"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground"
|
||||
:disabled="loading || !config.reasoning_effort.enabled || !formatConfig(format.key).enabled || !hasMappingParamChanges(format.key)"
|
||||
@click="saveMappingParam(format.key)"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center md:justify-end">
|
||||
<Switch
|
||||
:model-value="formatConfig(format.key).enabled"
|
||||
:disabled="loading || !config.reasoning_effort.enabled"
|
||||
@update:model-value="value => onApiFormatEnabledChange(format.key, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import {
|
||||
DEFAULT_REASONING_SUFFIXES,
|
||||
MODEL_DIRECTIVE_API_FORMATS,
|
||||
type ReasoningApiFormatConfig,
|
||||
type ModelDirectivesConfig,
|
||||
} from './modelDirectivesConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
config: ModelDirectivesConfig
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:config': [value: ModelDirectivesConfig]
|
||||
}>()
|
||||
|
||||
const selectedEfforts = reactive<Record<string, string>>({})
|
||||
const localMappingParams = reactive<Record<string, string>>({})
|
||||
|
||||
watch(() => props.config.reasoning_effort.api_formats, (newFormats) => {
|
||||
for (const format of MODEL_DIRECTIVE_API_FORMATS) {
|
||||
const fc = newFormats[format.key]
|
||||
const selectedEffort = selectedEfforts[format.key] ?? firstMappingEffort(fc?.mappings) ?? 'low'
|
||||
selectedEfforts[format.key] = selectedEffort
|
||||
const key = mappingKey(format.key, selectedEffort)
|
||||
if (localMappingParams[key] === undefined || !hasMappingParamChanges(format.key)) {
|
||||
localMappingParams[key] = JSON.stringify(fc?.mappings?.[selectedEffort] ?? {}, null, 2)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function formatConfig(apiFormat: string): ReasoningApiFormatConfig {
|
||||
return props.config.reasoning_effort.api_formats[apiFormat] ?? {
|
||||
enabled: true,
|
||||
mappings: {},
|
||||
}
|
||||
}
|
||||
|
||||
function firstMappingEffort(mappings: Record<string, unknown> | undefined): string | undefined {
|
||||
return DEFAULT_REASONING_SUFFIXES.find((effort) => mappings?.[effort] !== undefined)
|
||||
}
|
||||
|
||||
function mappingKey(apiFormat: string, effort = selectedEfforts[apiFormat] ?? 'low'): string {
|
||||
return `${apiFormat}:${effort}`
|
||||
}
|
||||
|
||||
function selectEffort(apiFormat: string, effort: string) {
|
||||
selectedEfforts[apiFormat] = effort
|
||||
const key = mappingKey(apiFormat, effort)
|
||||
localMappingParams[key] = JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function hasMappingParamChanges(apiFormat: string): boolean {
|
||||
const effort = selectedEfforts[apiFormat] ?? 'low'
|
||||
return (localMappingParams[mappingKey(apiFormat, effort)] ?? '') !== JSON.stringify(formatConfig(apiFormat).mappings[effort] ?? {}, null, 2)
|
||||
}
|
||||
|
||||
function onReasoningEnabledChange(value: boolean) {
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: { ...props.config.reasoning_effort, enabled: Boolean(value) },
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function onApiFormatEnabledChange(apiFormat: string, value: boolean) {
|
||||
const current = formatConfig(apiFormat)
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
api_formats: {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: { ...current, enabled: Boolean(value) },
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function saveMappingParam(apiFormat: string) {
|
||||
const current = formatConfig(apiFormat)
|
||||
const effort = selectedEfforts[apiFormat] ?? 'low'
|
||||
let mapping: unknown
|
||||
try {
|
||||
const parsed = JSON.parse(localMappingParams[mappingKey(apiFormat, effort)] || '{}')
|
||||
mapping = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(current.mappings[effort] ?? {}, null, 2)
|
||||
return
|
||||
}
|
||||
localMappingParams[mappingKey(apiFormat, effort)] = JSON.stringify(mapping, null, 2)
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
reasoning_effort: {
|
||||
...props.config.reasoning_effort,
|
||||
api_formats: {
|
||||
...props.config.reasoning_effort.api_formats,
|
||||
[apiFormat]: {
|
||||
...current,
|
||||
mappings: {
|
||||
...current.mappings,
|
||||
[effort]: mapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
emit('save')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface ReasoningApiFormatConfig {
|
||||
enabled: boolean
|
||||
mappings: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ModelDirectivesConfig {
|
||||
reasoning_effort: {
|
||||
enabled: boolean
|
||||
api_formats: Record<string, ReasoningApiFormatConfig>
|
||||
}
|
||||
}
|
||||
|
||||
export const MODEL_DIRECTIVES_MODULE_NAME = 'model_directives'
|
||||
|
||||
export const MODEL_DIRECTIVE_API_FORMATS = [
|
||||
{
|
||||
key: 'openai:chat',
|
||||
label: 'OpenAI Chat',
|
||||
parameter: 'reasoning_effort',
|
||||
},
|
||||
{
|
||||
key: 'openai:responses',
|
||||
label: 'OpenAI Responses',
|
||||
parameter: 'reasoning.effort',
|
||||
},
|
||||
{
|
||||
key: 'openai:responses:compact',
|
||||
label: 'OpenAI Responses Compact',
|
||||
parameter: 'reasoning.effort',
|
||||
},
|
||||
{
|
||||
key: 'claude:messages',
|
||||
label: 'Claude Messages',
|
||||
parameter: 'output_config.effort + thinking',
|
||||
},
|
||||
{
|
||||
key: 'gemini:generate_content',
|
||||
label: 'Gemini GenerateContent',
|
||||
parameter: 'generationConfig.thinkingConfig',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const DEFAULT_REASONING_SUFFIXES = ['low', 'medium', 'high', 'xhigh', 'max'] as const
|
||||
|
||||
function defaultMappingsForApiFormat(apiFormat: string): Record<string, unknown> {
|
||||
switch (apiFormat) {
|
||||
case 'openai:chat':
|
||||
return {
|
||||
low: { reasoning_effort: 'low' },
|
||||
medium: { reasoning_effort: 'medium' },
|
||||
high: { reasoning_effort: 'high' },
|
||||
xhigh: { reasoning_effort: 'xhigh' },
|
||||
max: { reasoning_effort: 'xhigh' },
|
||||
}
|
||||
case 'openai:responses':
|
||||
case 'openai:responses:compact':
|
||||
return {
|
||||
low: { reasoning: { effort: 'low' } },
|
||||
medium: { reasoning: { effort: 'medium' } },
|
||||
high: { reasoning: { effort: 'high' } },
|
||||
xhigh: { reasoning: { effort: 'xhigh' } },
|
||||
max: { reasoning: { effort: 'xhigh' } },
|
||||
}
|
||||
case 'claude:messages':
|
||||
return {
|
||||
low: { thinking: { type: 'enabled', budget_tokens: 1024 } },
|
||||
medium: { thinking: { type: 'enabled', budget_tokens: 4096 } },
|
||||
high: { thinking: { type: 'enabled', budget_tokens: 8192 } },
|
||||
xhigh: { thinking: { type: 'enabled', budget_tokens: 16384 } },
|
||||
max: { thinking: { type: 'enabled', budget_tokens: 32768 } },
|
||||
}
|
||||
case 'gemini:generate_content':
|
||||
return {
|
||||
low: { generationConfig: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
medium: { generationConfig: { thinkingConfig: { thinkingBudget: 4096 } } },
|
||||
high: { generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } },
|
||||
xhigh: { generationConfig: { thinkingConfig: { thinkingBudget: 16384 } } },
|
||||
max: { generationConfig: { thinkingConfig: { thinkingBudget: -1 } } },
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultModelDirectivesConfig(): ModelDirectivesConfig {
|
||||
return {
|
||||
reasoning_effort: {
|
||||
enabled: true,
|
||||
api_formats: Object.fromEntries(
|
||||
MODEL_DIRECTIVE_API_FORMATS.map((format) => [
|
||||
format.key,
|
||||
{
|
||||
enabled: true,
|
||||
mappings: defaultMappingsForApiFormat(format.key),
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mappingsFromLegacySuffixes(apiFormat: string, value: unknown): Record<string, unknown> {
|
||||
if (!Array.isArray(value)) return defaultMappingsForApiFormat(apiFormat)
|
||||
const supported = new Set<string>(DEFAULT_REASONING_SUFFIXES)
|
||||
const defaults = defaultMappingsForApiFormat(apiFormat)
|
||||
return Object.fromEntries(value
|
||||
.map((item) => String(item).trim().toLowerCase())
|
||||
.filter((item, index, array) => supported.has(item) && array.indexOf(item) === index)
|
||||
.map((suffix) => [suffix, defaults[suffix]])
|
||||
.filter(([, mapping]) => mapping !== undefined))
|
||||
}
|
||||
|
||||
function normalizeMappings(apiFormat: string, value: unknown, legacySuffixes: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return mappingsFromLegacySuffixes(apiFormat, legacySuffixes)
|
||||
}
|
||||
return { ...(value as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
export function normalizeModelDirectivesConfig(value: unknown): ModelDirectivesConfig {
|
||||
const defaults = createDefaultModelDirectivesConfig()
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
|
||||
const source = value as Partial<ModelDirectivesConfig>
|
||||
const reasoning = source.reasoning_effort
|
||||
const apiFormats: Record<string, ReasoningApiFormatConfig> = {
|
||||
...defaults.reasoning_effort.api_formats,
|
||||
}
|
||||
const sourceApiFormats = reasoning?.api_formats
|
||||
if (sourceApiFormats && typeof sourceApiFormats === 'object') {
|
||||
for (const [apiFormat, rawConfig] of Object.entries(sourceApiFormats)) {
|
||||
if (typeof rawConfig === 'boolean') {
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: rawConfig,
|
||||
mappings: defaultMappingsForApiFormat(apiFormat),
|
||||
}
|
||||
} else if (rawConfig && typeof rawConfig === 'object') {
|
||||
const value = rawConfig as Partial<ReasoningApiFormatConfig> & { suffixes?: unknown }
|
||||
apiFormats[apiFormat] = {
|
||||
enabled: typeof value.enabled === 'boolean' ? value.enabled : true,
|
||||
mappings: normalizeMappings(apiFormat, value.mappings, value.suffixes),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning_effort: {
|
||||
enabled:
|
||||
typeof reasoning?.enabled === 'boolean'
|
||||
? reasoning.enabled
|
||||
: defaults.reasoning_effort.enabled,
|
||||
api_formats: apiFormats,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user