From 20b27a13b247116acc209e0b601f083c1659ca0d Mon Sep 17 00:00:00 2001 From: MMEXA Date: Mon, 13 Jul 2026 21:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(codex):=20=E6=8C=89=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E8=B7=AF=E7=94=B1=20Responses=20V2=20?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 2fc604e047a63103232e0066780f24556c0bb36f) --- .../planner/candidate_materialization.rs | 6 + .../ai_serving/planner/candidate_source.rs | 112 ++++++++++-- .../planner/standard/family/candidates.rs | 3 + .../standard/openai/chat/decision/support.rs | 1 + .../standard/openai/chat/plans/candidates.rs | 1 + .../openai/responses/decision/support.rs | 9 +- .../src/ai_serving/planner/state/scheduler.rs | 35 +++- .../aether-gateway/src/ai_serving/pure/mod.rs | 2 +- .../src/cache/candidate_page.rs | 31 ++++ apps/aether-gateway/src/control/auth/gate.rs | 2 + .../src/data/candidate_selection.rs | 24 +++ apps/aether-gateway/src/data/tests.rs | 2 + .../query/models/model_test/model_mapping.rs | 18 ++ .../src/handlers/admin/request/models.rs | 9 +- .../handlers/public/support/user_me_usage.rs | 2 + .../src/scheduler/candidate/enumeration.rs | 6 +- .../src/scheduler/candidate/mod.rs | 37 ++++ .../src/scheduler/candidate/selection.rs | 4 + .../src/scheduler/candidate/tests/affinity.rs | 3 + .../src/scheduler/candidate/tests/model.rs | 6 + .../scheduler/candidate/tests/selection.rs | 5 + .../src/scheduler/candidate/tests/support.rs | 2 + apps/aether-gateway/src/state/catalog.rs | 1 + apps/aether-gateway/src/testkit.rs | 1 + .../src/tests/ai_execute/finalize_local.rs | 6 + .../ai_execute/finalize_local_cli/compact.rs | 1 + .../finalize_local_cli/cross_format.rs | 3 + .../ai_execute/finalize_local_cli/direct.rs | 2 + .../finalize_local_provider/claude.rs | 3 + .../finalize_local_provider/gemini.rs | 4 + .../src/tests/ai_execute/lifecycle.rs | 1 + .../src/tests/ai_execute/stream/decision.rs | 5 + .../src/tests/ai_execute/stream/image.rs | 3 + .../tests/ai_execute/stream/pii_redaction.rs | 1 + .../tests/ai_execute/stream_cli/compact.rs | 1 + .../src/tests/ai_execute/stream_cli/direct.rs | 1 + .../src/tests/ai_execute/stream_provider.rs | 4 + .../stream_provider_gemini/local_chat.rs | 1 + .../stream_provider_gemini/local_cli.rs | 4 + .../tests/ai_execute/sync/chat/failover.rs | 4 + .../ai_execute/sync/chat/local_decision.rs | 11 ++ .../ai_execute/sync/chat/pii_redaction.rs | 1 + .../ai_execute/sync/claude/claude_code.rs | 1 + .../src/tests/ai_execute/sync/claude/kiro.rs | 2 + .../ai_execute/sync/claude/local_chat.rs | 2 + .../tests/ai_execute/sync/claude/local_cli.rs | 3 + .../src/tests/ai_execute/sync/cli.rs | 9 + .../src/tests/ai_execute/sync/gemini/cli.rs | 5 + .../ai_execute/sync/gemini/local_chat.rs | 2 + .../src/tests/ai_execute/sync/image.rs | 4 + .../ai_execute/sync/pii_redaction_formats.rs | 1 + .../src/tests/ai_execute/sync/search.rs | 1 + apps/aether-gateway/src/tests/audit.rs | 1 + apps/aether-gateway/src/tests/files/mod.rs | 1 + apps/aether-gateway/src/tests/frontdoor.rs | 1 + apps/aether-gateway/src/tests/frontdoor/ai.rs | 1 + apps/aether-gateway/src/tests/usage.rs | 1 + apps/aether-gateway/src/tests/usage/local.rs | 2 + .../aether-gateway/src/tests/usage/pricing.rs | 1 + .../src/tests/video/gemini_sync_create.rs | 1 + .../src/tests/video/openai_sync_create.rs | 1 + .../aether-admin/src/observability/usage.rs | 2 + .../src/formats/openai/responses/mod.rs | 66 +++++++ crates/aether-ai/formats/src/lib.rs | 3 + .../adapters/mysql/src/candidate_selection.rs | 17 ++ .../postgres/src/candidate_selection.rs | 23 ++- .../sqlite/src/candidate_selection.rs | 27 ++- .../repository/candidate_selection/types.rs | 4 + .../repository/candidate_selection/memory.rs | 3 + .../src/candidate/enumeration.rs | 4 +- .../src/candidate/mod.rs | 5 + .../src/candidate/types.rs | 1 + crates/aether-scheduler-core/src/lib.rs | 11 +- crates/aether-scheduler-core/src/model.rs | 169 +++++++++++++++--- crates/aether-usage/runtime/src/write.rs | 29 ++- 75 files changed, 732 insertions(+), 50 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs index 64fce324d..1648ada25 100644 --- a/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs +++ b/apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs @@ -706,6 +706,7 @@ pub(crate) async fn build_lazy_requested_model_execution_candidate_attempt_sourc trace_id: &str, client_api_format: &str, requested_model: &str, + request_operation: Option<&str>, require_streaming: bool, auth_snapshot: &GatewayAuthApiKeySnapshot, client_session_affinity: Option<&ClientSessionAffinity>, @@ -734,6 +735,7 @@ where model_directive_policy, client_api_format, requested_model, + request_operation, require_streaming, required_capabilities, auth_snapshot, @@ -1141,6 +1143,7 @@ async fn resolve_priority_candidate_page_with_cache( let key = CandidateResolvedPageCacheKey::new( &cursor.requested_model, + cursor.page_cursor.resolved_page_cache_request_operation(), &cursor.client_api_format, true, &cursor.auth_snapshot, @@ -2190,6 +2193,7 @@ mod tests { &model_directive_policy, "openai:chat", "gpt-5", + None, true, None, &auth_snapshot, @@ -2245,6 +2249,7 @@ mod tests { &model_directive_policy, "openai:chat", "gpt-5", + None, true, None, &auth_snapshot, @@ -2282,6 +2287,7 @@ mod tests { &model_directive_policy, "openai:chat", "gpt-5", + None, true, None, &auth_snapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs index 1ff3fc292..ba48d33eb 100644 --- a/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs +++ b/apps/aether-gateway/src/ai_serving/planner/candidate_source.rs @@ -6,9 +6,10 @@ use aether_routing_core::ResolvedRoutingPolicy; use aether_runtime::ConcurrencyPermit; use aether_scheduler_core::{ 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, ClientSessionAffinity, - EnumerateMinimalCandidateSelectionInput, SchedulerMinimalCandidateSelectionCandidate, + resolve_requested_global_model_name_with_model_directives_and_request_operation, + row_supports_requested_model_with_model_directives_and_request_operation, + ClientSessionAffinity, EnumerateMinimalCandidateSelectionInput, + SchedulerMinimalCandidateSelectionCandidate, }; use async_trait::async_trait; use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -56,6 +57,7 @@ struct GatewayLocalCandidatePreselectionPort<'a> { state: PlannerAppState<'a>, client_api_format: &'a str, requested_model: &'a str, + request_operation: Option<&'a str>, require_streaming: bool, required_capabilities: Option<&'a serde_json::Value>, auth_snapshot: &'a GatewayAuthApiKeySnapshot, @@ -112,7 +114,7 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> { let auth_snapshot = matches_client_format.then_some(self.auth_snapshot); let (candidates, skipped_candidates) = self .state - .list_selectable_candidates_with_skip_reasons( + .list_selectable_candidates_with_skip_reasons_for_request_operation( candidate_api_format, self.routing_model(candidate_api_format), self.require_streaming, @@ -121,6 +123,7 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> { self.client_session_affinity, self.ranking_seed, false, + self.request_operation, ) .await?; @@ -197,6 +200,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving( model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot, client_api_format: &str, requested_model: &str, + request_operation: Option<&str>, require_streaming: bool, required_capabilities: Option<&serde_json::Value>, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -221,6 +225,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving( model_directive_policy, client_api_format, requested_model, + request_operation, require_streaming, required_capabilities, auth_snapshot, @@ -239,6 +244,7 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot, client_api_format: &str, requested_model: &str, + request_operation: Option<&str>, require_streaming: bool, required_capabilities: Option<&serde_json::Value>, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -263,6 +269,7 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se state, client_api_format, requested_model, + request_operation, require_streaming, required_capabilities, auth_snapshot, @@ -283,6 +290,7 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> { trace_id: String, client_api_format: String, requested_model: String, + request_operation: Option, require_streaming: bool, required_capabilities: Option, auth_snapshot: GatewayAuthApiKeySnapshot, @@ -336,6 +344,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot, client_api_format: &str, requested_model: &str, + request_operation: Option<&str>, require_streaming: bool, required_capabilities: Option<&serde_json::Value>, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -370,6 +379,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { trace_id: trace_id.unwrap_or_default().to_string(), client_api_format: client_api_format.to_string(), requested_model: requested_model.to_string(), + request_operation: request_operation.map(str::to_string), require_streaming, required_capabilities: required_capabilities.cloned(), auth_snapshot: auth_snapshot.clone(), @@ -452,6 +462,10 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { self.key_mode.cache_key_name() } + pub(crate) fn resolved_page_cache_request_operation(&self) -> Option<&str> { + self.request_operation.as_deref() + } + pub(crate) fn resolved_page_cache_use_api_format_alias_match(&self) -> bool { self.use_api_format_alias_match } @@ -515,6 +529,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { > { let key = CandidatePageCacheKey::new( &self.requested_model, + self.request_operation.as_deref(), &self.client_api_format, self.require_streaming, &self.auth_snapshot, @@ -965,11 +980,12 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { .map_err(|err| GatewayError::Internal(err.to_string()))? .into_iter() .filter(|row| { - row_supports_requested_model_with_model_directives( + row_supports_requested_model_with_model_directives_and_request_operation( row, &routing_model, normalized_api_format, false, + self.request_operation.as_deref(), ) }) .collect::>(); @@ -1009,12 +1025,15 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { if let Some(value) = self.resolved_global_model_names.get(normalized_api_format) { value.clone() } else { - let Some(value) = resolve_requested_global_model_name_with_model_directives( - &rows, - &routing_model, - normalized_api_format, - false, - ) else { + let Some(value) = + resolve_requested_global_model_name_with_model_directives_and_request_operation( + &rows, + &routing_model, + normalized_api_format, + false, + self.request_operation.as_deref(), + ) + else { return Ok(None); }; self.resolved_global_model_names @@ -1037,6 +1056,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> { EnumerateMinimalCandidateSelectionInput { rows, normalized_api_format, + request_operation: self.request_operation.as_deref(), requested_model_name: &routing_model, resolved_global_model_name: resolved_global_model_name.as_str(), require_streaming: self.require_streaming, @@ -1316,6 +1336,7 @@ mod tests { &model_directive_policy, "openai:chat", "gpt-5", + None, true, None, &auth_snapshot, @@ -1531,6 +1552,7 @@ mod tests { priority: 1, api_formats: None, endpoint_ids: Some(vec!["endpoint-opg-openai".to_string()]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1561,6 +1583,7 @@ mod tests { &model_directive_policy, "claude:messages", "gpt-5.5-xhigh", + None, false, None, &auth_snapshot, @@ -1590,6 +1613,70 @@ mod tests { ); } + #[tokio::test] + async fn paged_preselection_prefers_operation_scoped_mapping_for_compaction() { + let mut row = openai_responses_mapping_row(); + row.global_model_mappings = None; + row.global_model_name = "gpt-5.6-sol".to_string(); + row.model_provider_model_name = "gpt-5.6-sol".to_string(); + row.model_provider_model_mappings = Some(vec![ + StoredProviderModelMapping { + name: "gpt-5.6-sol".to_string(), + priority: 1, + api_formats: Some(vec!["openai:responses".to_string()]), + endpoint_ids: None, + operations: None, + }, + StoredProviderModelMapping { + name: "gpt-5.6-terra".to_string(), + priority: 1, + api_formats: Some(vec!["openai:responses".to_string()]), + endpoint_ids: None, + operations: Some(vec!["compact".to_string()]), + }, + ]); + let repository: Arc = + Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([row])); + let data_state = + GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository); + let app = AppState::new() + .expect("gateway state should build") + .with_data_state_for_tests(data_state); + let auth_snapshot = unrestricted_auth_snapshot(); + let model_directive_policy = + crate::system_features::ModelDirectivePolicySnapshot::load(&app).await; + let mut cursor = LocalCandidatePreselectionPageCursor::new( + PlannerAppState::new(&app), + &model_directive_policy, + "openai:responses", + "gpt-5.6-sol", + Some("compact"), + false, + None, + &auth_snapshot, + None, + None, + None, + true, + LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat, + true, + None, + ) + .await; + + let page = cursor + .next_page() + .await + .expect("preselection should succeed") + .expect("compact mapping should find a provider"); + + assert_eq!(page.candidates.len(), 1); + assert_eq!( + page.candidates[0].selected_provider_model_name, + "gpt-5.6-terra" + ); + } + #[tokio::test] async fn custom_policy_suffix_uses_the_same_base_model_for_candidate_selection() { let mut row = openai_responses_mapping_row(); @@ -1634,6 +1721,7 @@ mod tests { &model_directive_policy, "openai:responses", "deployment-alias-VendorFuture", + None, false, None, &auth_snapshot, @@ -1695,6 +1783,7 @@ mod tests { &model_directive_policy, "claude:messages", "deepseek-v4-pro", + None, false, None, &auth_snapshot, @@ -1773,6 +1862,7 @@ mod tests { &model_directive_policy, "claude:messages", "gpt-5", + None, false, None, &auth_snapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs b/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs index 8cea21bd6..ffe92f270 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/family/candidates.rs @@ -124,6 +124,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts( &input.model_directive_policy, spec_metadata.api_format, &input.requested_model, + None, false, input.required_capabilities.as_ref(), &input.auth_snapshot, @@ -250,6 +251,7 @@ pub(super) async fn build_local_standard_candidate_attempt_source<'a>( trace_id, spec_metadata.api_format, &input.requested_model, + None, spec_metadata.require_streaming, &input.auth_snapshot, input.client_session_affinity.as_ref(), @@ -345,6 +347,7 @@ async fn maybe_append_gemini_image_openai_image_preselection( &input.model_directive_policy, spec_metadata.api_format, &input.requested_model, + None, spec_metadata.require_streaming, input.required_capabilities.as_ref(), &input.auth_snapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs index f57237ec2..4a61a6484 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/support.rs @@ -297,6 +297,7 @@ pub(crate) async fn build_lazy_local_openai_chat_candidate_attempt_source<'a>( trace_id, "openai:chat", &input.requested_model, + None, require_streaming, &input.auth_snapshot, input.client_session_affinity.as_ref(), diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs index e0db3b2b1..0b644d19a 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/candidates.rs @@ -24,6 +24,7 @@ pub(crate) async fn list_local_openai_chat_candidates( &input.model_directive_policy, "openai:chat", &input.requested_model, + None, require_streaming, input.required_capabilities.as_ref(), &input.auth_snapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs index c44d3782c..3ad3674c0 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/support.rs @@ -31,8 +31,8 @@ use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metad use crate::ai_serving::planner::CandidateFailureDiagnostic; use crate::ai_serving::{ ai_local_execution_contract_for_formats, extract_pool_sticky_session_token, - resolve_local_decision_execution_runtime_auth_context, ExecutionRuntimeAuthContext, - GatewayControlDecision, PlannerAppState, + openai_responses_request_operation, resolve_local_decision_execution_runtime_auth_context, + ExecutionRuntimeAuthContext, GatewayControlDecision, PlannerAppState, }; use crate::client_session_affinity::client_session_affinity_from_parts; use crate::{AppState, GatewayError}; @@ -163,6 +163,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts( spec: LocalOpenAiResponsesSpec, ) -> Result<(Vec, usize), GatewayError> { let spec_metadata = local_openai_responses_spec_metadata(spec); + let request_operation = openai_responses_request_operation(spec_metadata.api_format, body_json); let planner_state = PlannerAppState::new(state); let sticky_session_token = extract_pool_sticky_session_token(body_json); let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context; @@ -176,6 +177,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts( &input.model_directive_policy, spec_metadata.api_format, &input.requested_model, + request_operation, spec_metadata.require_streaming, input.required_capabilities.as_ref(), &input.auth_snapshot, @@ -262,6 +264,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>( spec: LocalOpenAiResponsesSpec, ) -> Result<(LocalOpenAiResponsesCandidateAttemptSource<'a>, usize), GatewayError> { let spec_metadata = local_openai_responses_spec_metadata(spec); + let request_operation = openai_responses_request_operation(spec_metadata.api_format, body_json); let planner_state = PlannerAppState::new(state); let sticky_session_token = extract_pool_sticky_session_token(body_json); let auth_context: &ExecutionRuntimeAuthContext = &input.auth_context; @@ -287,6 +290,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>( trace_id, spec_metadata.api_format, &input.requested_model, + request_operation, spec_metadata.require_streaming, &input.auth_snapshot, input.client_session_affinity.as_ref(), @@ -372,6 +376,7 @@ pub(crate) async fn build_local_openai_responses_image_candidate_attempt_source< &input.model_directive_policy, spec_metadata.api_format, &input.requested_model, + None, false, input.required_capabilities.as_ref(), &input.auth_snapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs b/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs index 22571c73b..916307790 100644 --- a/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs +++ b/apps/aether-gateway/src/ai_serving/planner/state/scheduler.rs @@ -53,13 +53,45 @@ impl<'a> PlannerAppState<'a> { Vec, ), GatewayError, + > { + self.list_selectable_candidates_with_skip_reasons_for_request_operation( + api_format, + global_model_name, + require_streaming, + required_capabilities, + auth_snapshot, + client_session_affinity, + now_unix_secs, + enable_model_directives, + None, + ) + .await + } + + pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation( + self, + api_format: &str, + global_model_name: &str, + require_streaming: bool, + required_capabilities: Option<&serde_json::Value>, + auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, + client_session_affinity: Option<&ClientSessionAffinity>, + now_unix_secs: u64, + enable_model_directives: bool, + request_operation: Option<&str>, + ) -> Result< + ( + Vec, + Vec, + ), + GatewayError, > { let wait_timeout = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_TIMEOUT_MS); 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; loop { - let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons( + let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons_for_request_operation( self.app().data.as_ref(), self.app(), api_format, @@ -70,6 +102,7 @@ impl<'a> PlannerAppState<'a> { client_session_affinity, attempt_now_unix_secs, enable_model_directives, + request_operation, ) .await?; diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index c1a22da71..585ef13ad 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -165,5 +165,5 @@ pub(crate) use aether_ai_formats::api::{ pub(crate) use aether_ai_formats::{ api_format_defaults_to_client_error_failover, api_format_defaults_to_non_stream, api_format_permission_covers, intersect_api_format_allowed_lists, is_embedding_api_format, - is_rerank_api_format, + is_rerank_api_format, openai_responses_request_operation, }; diff --git a/apps/aether-gateway/src/cache/candidate_page.rs b/apps/aether-gateway/src/cache/candidate_page.rs index 0c22546bf..1b6323e74 100644 --- a/apps/aether-gateway/src/cache/candidate_page.rs +++ b/apps/aether-gateway/src/cache/candidate_page.rs @@ -82,6 +82,7 @@ pub(crate) struct CandidateRowPageCacheKey { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CandidatePageCacheKey { requested_model: String, + request_operation: String, client_api_format: String, auth_identity: CandidatePageAuthIdentity, require_streaming: bool, @@ -131,6 +132,7 @@ impl CandidatePageCacheKey { #[allow(clippy::too_many_arguments)] pub(crate) fn new( requested_model: &str, + request_operation: Option<&str>, client_api_format: &str, require_streaming: bool, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -145,6 +147,7 @@ impl CandidatePageCacheKey { ) -> Self { Self { requested_model: normalize_text_key(requested_model), + request_operation: normalize_text_key(request_operation.unwrap_or_default()), client_api_format: normalize_api_format(client_api_format), auth_identity: CandidatePageAuthIdentity::from_auth_snapshot(auth_snapshot), require_streaming, @@ -164,6 +167,7 @@ impl CandidateResolvedPageCacheKey { #[allow(clippy::too_many_arguments)] pub(crate) fn new( requested_model: &str, + request_operation: Option<&str>, client_api_format: &str, require_streaming: bool, auth_snapshot: &GatewayAuthApiKeySnapshot, @@ -180,6 +184,7 @@ impl CandidateResolvedPageCacheKey { Self { page_key: CandidatePageCacheKey::new( requested_model, + request_operation, client_api_format, require_streaming, auth_snapshot, @@ -564,6 +569,7 @@ mod tests { let auth_b = auth_snapshot("user-b", "key-a"); let base = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -578,6 +584,7 @@ mod tests { ); let different_user = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_b, @@ -592,6 +599,22 @@ mod tests { ); let different_model = CandidatePageCacheKey::new( "gpt-4.1", + None, + "openai:chat", + true, + &auth_a, + Some(&json!({"vision": true})), + None, + Some("bearer"), + 7, + "provider_endpoint_key_model", + true, + None, + "policy-a", + ); + let different_operation = CandidatePageCacheKey::new( + "gpt-4o", + Some("compact"), "openai:chat", true, &auth_a, @@ -606,6 +629,7 @@ mod tests { ); let different_format = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:responses", true, &auth_a, @@ -620,6 +644,7 @@ mod tests { ); let different_capabilities = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -634,6 +659,7 @@ mod tests { ); let same_policy = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -648,6 +674,7 @@ mod tests { ); let different_policy = CandidatePageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -664,12 +691,14 @@ mod tests { assert_eq!(base, same_policy); assert_ne!(base, different_user); assert_ne!(base, different_model); + assert_ne!(base, different_operation); assert_ne!(base, different_format); assert_ne!(base, different_capabilities); assert_ne!(base, different_policy); let resolved_base = CandidateResolvedPageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -685,6 +714,7 @@ mod tests { ); let resolved_same_policy = CandidateResolvedPageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, @@ -700,6 +730,7 @@ mod tests { ); let resolved_different_policy = CandidateResolvedPageCacheKey::new( "gpt-4o", + None, "openai:chat", true, &auth_a, diff --git a/apps/aether-gateway/src/control/auth/gate.rs b/apps/aether-gateway/src/control/auth/gate.rs index 68b462671..cf5796d8e 100644 --- a/apps/aether-gateway/src/control/auth/gate.rs +++ b/apps/aether-gateway/src/control/auth/gate.rs @@ -621,6 +621,7 @@ mod tests { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -954,6 +955,7 @@ mod tests { priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]); let state = state_with_rows(vec![row]); let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]); diff --git a/apps/aether-gateway/src/data/candidate_selection.rs b/apps/aether-gateway/src/data/candidate_selection.rs index a8a5d794b..003a61661 100644 --- a/apps/aether-gateway/src/data/candidate_selection.rs +++ b/apps/aether-gateway/src/data/candidate_selection.rs @@ -239,6 +239,29 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, required_capabilities: Option<&serde_json::Value>, enable_model_directives: bool, +) -> Result, DataLayerError> { + enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation( + state, + api_format, + requested_model_name, + require_streaming, + auth_snapshot, + required_capabilities, + enable_model_directives, + None, + ) + .await +} + +pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation( + state: &(impl MinimalCandidateSelectionRowSource + Sync), + api_format: &str, + requested_model_name: &str, + require_streaming: bool, + auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, + required_capabilities: Option<&serde_json::Value>, + enable_model_directives: bool, + request_operation: Option<&str>, ) -> Result, DataLayerError> { let normalized_api_format = normalize_api_format(api_format); if normalized_api_format.is_empty() { @@ -267,6 +290,7 @@ pub(crate) async fn enumerate_minimal_candidate_selection_with_required_capabili EnumerateMinimalCandidateSelectionInput { rows, normalized_api_format: &normalized_api_format, + request_operation, requested_model_name, resolved_global_model_name: resolved_global_model_name.as_str(), require_streaming, diff --git a/apps/aether-gateway/src/data/tests.rs b/apps/aether-gateway/src/data/tests.rs index 7a3f27557..25c1154a0 100644 --- a/apps/aether-gateway/src/data/tests.rs +++ b/apps/aether-gateway/src/data/tests.rs @@ -590,6 +590,7 @@ fn sample_minimal_candidate_selection_row( priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: None, model_is_active: true, @@ -907,6 +908,7 @@ async fn data_state_reads_minimal_candidate_selection_with_auth_filters() { enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows, normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-4.1", resolved_global_model_name: "gpt-4.1", require_streaming: false, diff --git a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs index e4f670e40..ed456959d 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/model_mapping.rs @@ -213,6 +213,7 @@ fn provider_query_parse_embedded_provider_model_mappings( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }])) } @@ -237,6 +238,7 @@ fn provider_query_parse_provider_model_mappings_array( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }); } } @@ -293,6 +295,11 @@ fn provider_query_parse_provider_model_mapping_object_lenient( object.get("endpoint_ids"), "models.provider_model_mappings.endpoint_ids", )?; + let operations = provider_query_parse_mapping_string_list( + object.get("operations"), + "models.provider_model_mappings.operations", + )? + .and_then(provider_query_normalize_request_operations); Ok(Some(StoredProviderModelMapping { name: name.to_string(), @@ -303,9 +310,19 @@ fn provider_query_parse_provider_model_mapping_object_lenient( })?, api_formats, endpoint_ids, + operations, })) } +fn provider_query_normalize_request_operations(values: Vec) -> Option> { + let operations = values + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + (!operations.is_empty()).then_some(operations) +} + fn provider_query_parse_mapping_string_list( value: Option<&Value>, field_name: &str, @@ -389,6 +406,7 @@ mod tests { priority: 1, api_formats: Some(vec![api_format.to_string()]), endpoint_ids: None, + operations: None, } } diff --git a/apps/aether-gateway/src/handlers/admin/request/models.rs b/apps/aether-gateway/src/handlers/admin/request/models.rs index 1da58be85..b24daf716 100644 --- a/apps/aether-gateway/src/handlers/admin/request/models.rs +++ b/apps/aether-gateway/src/handlers/admin/request/models.rs @@ -15,7 +15,7 @@ use serde_json::json; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; -fn normalize_provider_model_mappings_api_formats( +fn normalize_provider_model_mapping_scopes( value: Option, ) -> Option { let Some(mut value) = value else { @@ -36,6 +36,9 @@ fn normalize_provider_model_mappings_api_formats( normalize_provider_model_mapping_string_array_field(object, "endpoint_ids", |value| { value.trim().to_string() }); + normalize_provider_model_mapping_string_array_field(object, "operations", |value| { + value.trim().to_ascii_lowercase() + }); } Some(value) } @@ -155,7 +158,7 @@ impl<'a> AdminAppState<'a> { "price_per_request", )?; let tiered_pricing = normalize_json_object(payload.tiered_pricing, "tiered_pricing")?; - let provider_model_mappings = normalize_provider_model_mappings_api_formats( + let provider_model_mappings = normalize_provider_model_mapping_scopes( normalize_json_array(payload.provider_model_mappings, "provider_model_mappings")?, ); let config = normalize_json_object(payload.config, "config")?; @@ -241,7 +244,7 @@ impl<'a> AdminAppState<'a> { existing.tiered_pricing.clone() }; let provider_model_mappings = if fields.contains("provider_model_mappings") { - normalize_provider_model_mappings_api_formats(normalize_json_array( + normalize_provider_model_mapping_scopes(normalize_json_array( payload.provider_model_mappings, "provider_model_mappings", )?) diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs index 5c63cef02..e66900885 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs @@ -488,6 +488,7 @@ fn build_users_me_usage_record_payload( "cache_read_input_tokens": item.cache_read_input_tokens, "status_code": item.status_code, "error_message": item.error_message, + "request_type": item.request_type, "input_price_per_1m": input_price_per_1m, "output_price_per_1m": output_price_per_1m, "cache_creation_price_per_1m": cache_creation_price_per_1m, @@ -525,6 +526,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_ let mut payload = json!({ "id": item.id, "status": item.status, + "request_type": item.request_type, "input_tokens": item.input_tokens, "effective_input_tokens": users_me_usage_effective_input_tokens(item), "output_tokens": item.output_tokens, diff --git a/apps/aether-gateway/src/scheduler/candidate/enumeration.rs b/apps/aether-gateway/src/scheduler/candidate/enumeration.rs index c04fc1cfe..12a6f4f21 100644 --- a/apps/aether-gateway/src/scheduler/candidate/enumeration.rs +++ b/apps/aether-gateway/src/scheduler/candidate/enumeration.rs @@ -2,7 +2,7 @@ use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate; use crate::data::auth::GatewayAuthApiKeySnapshot; use crate::data::candidate_selection::{ - enumerate_minimal_candidate_selection_with_required_capabilities, + enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation, MinimalCandidateSelectionRowSource, }; use crate::GatewayError; @@ -15,8 +15,9 @@ pub(super) async fn enumerate_scheduler_candidates( required_capabilities: Option<&serde_json::Value>, auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, enable_model_directives: bool, + request_operation: Option<&str>, ) -> Result, GatewayError> { - enumerate_minimal_candidate_selection_with_required_capabilities( + enumerate_minimal_candidate_selection_with_required_capabilities_for_request_operation( selection_row_source, api_format, global_model_name, @@ -24,6 +25,7 @@ pub(super) async fn enumerate_scheduler_candidates( auth_snapshot, required_capabilities, enable_model_directives, + request_operation, ) .await .map_err(|err| GatewayError::Internal(err.to_string())) diff --git a/apps/aether-gateway/src/scheduler/candidate/mod.rs b/apps/aether-gateway/src/scheduler/candidate/mod.rs index f793cef21..1da74c2ad 100644 --- a/apps/aether-gateway/src/scheduler/candidate/mod.rs +++ b/apps/aether-gateway/src/scheduler/candidate/mod.rs @@ -116,6 +116,42 @@ pub(crate) async fn list_selectable_candidates_with_skip_reasons( client_session_affinity, now_unix_secs, enable_model_directives, + None, + ) + .await +} + +pub(crate) async fn list_selectable_candidates_with_skip_reasons_for_request_operation( + selection_row_source: &(impl MinimalCandidateSelectionRowSource + Sync), + runtime_state: &impl SchedulerRuntimeState, + api_format: &str, + global_model_name: &str, + require_streaming: bool, + required_capabilities: Option<&serde_json::Value>, + auth_snapshot: Option<&GatewayAuthApiKeySnapshot>, + client_session_affinity: Option<&ClientSessionAffinity>, + now_unix_secs: u64, + enable_model_directives: bool, + request_operation: Option<&str>, +) -> Result< + ( + Vec, + Vec, + ), + GatewayError, +> { + collect_selectable_candidates_with_skip_reasons( + selection_row_source, + runtime_state, + api_format, + global_model_name, + require_streaming, + required_capabilities, + auth_snapshot, + client_session_affinity, + now_unix_secs, + enable_model_directives, + request_operation, ) .await } @@ -237,6 +273,7 @@ pub(crate) async fn list_selectable_candidates_for_required_capability_without_r client_session_affinity, now_unix_secs, false, + None, ) .await?; all_attempts_blocked_by_auth_limit &= diff --git a/apps/aether-gateway/src/scheduler/candidate/selection.rs b/apps/aether-gateway/src/scheduler/candidate/selection.rs index 7ef674b11..5a784dced 100644 --- a/apps/aether-gateway/src/scheduler/candidate/selection.rs +++ b/apps/aether-gateway/src/scheduler/candidate/selection.rs @@ -81,6 +81,7 @@ pub(super) async fn select_minimal_candidate( required_capabilities, auth_snapshot, enable_model_directives, + None, ) .await?; let selected = collect_selectable_enumerated_candidates_with_skip_reasons( @@ -137,6 +138,7 @@ pub(super) async fn collect_selectable_candidates( client_session_affinity, now_unix_secs, enable_model_directives, + None, ) .await? .0) @@ -153,6 +155,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons( client_session_affinity: Option<&ClientSessionAffinity>, now_unix_secs: u64, enable_model_directives: bool, + request_operation: Option<&str>, ) -> Result< ( Vec, @@ -174,6 +177,7 @@ pub(super) async fn collect_selectable_candidates_with_skip_reasons( required_capabilities, auth_snapshot, enable_model_directives, + request_operation, ) .await?; collect_selectable_enumerated_candidates_with_skip_reasons( diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs b/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs index 50facf3dc..0fe2b2467 100644 --- a/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs +++ b/apps/aether-gateway/src/scheduler/candidate/tests/affinity.rs @@ -107,6 +107,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let mut second = sample_row(); @@ -124,6 +125,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ @@ -142,6 +144,7 @@ async fn same_priority_candidates_are_distributed_by_affinity_key() { enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows, normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-4.1", resolved_global_model_name: "gpt-4.1", require_streaming: false, diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/model.rs b/apps/aether-gateway/src/scheduler/candidate/tests/model.rs index 94b79ed2c..cfe925f84 100644 --- a/apps/aether-gateway/src/scheduler/candidate/tests/model.rs +++ b/apps/aether-gateway/src/scheduler/candidate/tests/model.rs @@ -45,6 +45,7 @@ fn provider_model_mapping_respects_endpoint_scope() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec!["endpoint-selected".to_string()]), + operations: None, }]); assert_eq!( @@ -101,6 +102,7 @@ fn resolves_requested_global_model_from_provider_model_alias() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let resolved = resolve_requested_global_model_name(&[row], "gpt-5.2", "openai:chat"); @@ -156,6 +158,7 @@ async fn enumerate_minimal_candidate_selection_resolves_provider_model_alias() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ @@ -193,6 +196,7 @@ async fn enumerate_minimal_candidate_selection_filters_endpoint_scoped_alias_row priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec!["endpoint-selected".to_string()]), + operations: None, }]); let mut other = selected.clone(); @@ -248,6 +252,7 @@ async fn enumerate_minimal_candidate_selection_keeps_only_resolved_global_model_ priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ @@ -287,6 +292,7 @@ async fn enumerate_minimal_candidate_selection_allows_resolved_global_model_in_a priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs b/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs index 824964199..b1578c777 100644 --- a/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs +++ b/apps/aether-gateway/src/scheduler/candidate/tests/selection.rs @@ -105,6 +105,7 @@ async fn collect_selectable_candidates_with_skip_reasons( None, now_unix_secs, false, + None, ) .await } @@ -843,6 +844,7 @@ async fn selects_next_candidate_when_first_provider_quota_is_exhausted() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); first.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 1})); @@ -858,6 +860,7 @@ async fn selects_next_candidate_when_first_provider_quota_is_exhausted() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); second.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 2})); @@ -914,6 +917,7 @@ async fn cooled_down_when_recent_failures_are_recorded_for_same_key() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); first.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 1})); @@ -929,6 +933,7 @@ async fn cooled_down_when_recent_failures_are_recorded_for_same_key() { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); second.key_global_priority_by_format = Some(serde_json::json!({"openai:chat": 2})); diff --git a/apps/aether-gateway/src/scheduler/candidate/tests/support.rs b/apps/aether-gateway/src/scheduler/candidate/tests/support.rs index e1e17dd78..1bf9e6f13 100644 --- a/apps/aether-gateway/src/scheduler/candidate/tests/support.rs +++ b/apps/aether-gateway/src/scheduler/candidate/tests/support.rs @@ -40,12 +40,14 @@ pub(super) fn sample_row() -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }, StoredProviderModelMapping { name: "gpt-4.1-responses".to_string(), priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }, ]), model_supports_streaming: None, diff --git a/apps/aether-gateway/src/state/catalog.rs b/apps/aether-gateway/src/state/catalog.rs index 1e3d5a823..b61fa3f4c 100644 --- a/apps/aether-gateway/src/state/catalog.rs +++ b/apps/aether-gateway/src/state/catalog.rs @@ -1404,6 +1404,7 @@ mod tests { let ttl = Duration::from_secs(300); let cache_key = CandidatePageCacheKey::new( "gpt-5", + None, "openai:chat", true, &sample_auth_snapshot(), diff --git a/apps/aether-gateway/src/testkit.rs b/apps/aether-gateway/src/testkit.rs index 98f74f5a5..04d40c6e8 100644 --- a/apps/aether-gateway/src/testkit.rs +++ b/apps/aether-gateway/src/testkit.rs @@ -247,6 +247,7 @@ fn openai_chat_pressure_candidates( priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec![pressure_endpoint_id(index)]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs index 7a787497c..c2103c842 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs @@ -138,6 +138,7 @@ async fn gateway_executes_openai_chat_sync_upstream_stream_via_local_finalize_re priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -637,6 +638,7 @@ async fn gateway_executes_openai_chat_cross_format_upstream_stream_via_local_fin priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1082,6 +1084,7 @@ async fn gateway_executes_openai_chat_cross_format_tool_use_upstream_stream_via_ priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1564,6 +1567,7 @@ async fn gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_fi priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2074,6 +2078,7 @@ async fn gateway_executes_openai_chat_cross_format_claude_upstream_sync_via_loca priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2433,6 +2438,7 @@ async fn gateway_executes_openai_chat_cross_format_gemini_upstream_sync_via_loca priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs index 58f5fe141..2232af5ca 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/compact.rs @@ -113,6 +113,7 @@ async fn gateway_executes_openai_responses_compact_openai_family_upstream_stream priority: 1, api_formats: Some(vec!["openai:responses:compact".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs index af570cfbd..03471fe8b 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs @@ -113,6 +113,7 @@ async fn gateway_executes_openai_responses_cross_format_upstream_stream_via_loca priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -586,6 +587,7 @@ async fn gateway_executes_openai_responses_cross_format_function_call_upstream_s priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1071,6 +1073,7 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs index 7a35e3dde..429e52774 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs @@ -135,6 +135,7 @@ async fn gateway_executes_openai_responses_sync_upstream_stream_via_local_finali priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -650,6 +651,7 @@ async fn gateway_executes_kiro_claude_cli_sync_upstream_stream_via_local_finaliz priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs index 101a2b829..5991495b0 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/claude.rs @@ -131,6 +131,7 @@ async fn gateway_executes_claude_chat_sync_same_format_via_local_finalize_respon priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -587,6 +588,7 @@ async fn gateway_executes_claude_chat_sync_upstream_stream_via_local_finalize_re priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1048,6 +1050,7 @@ async fn gateway_executes_claude_cli_sync_upstream_stream_via_local_finalize_res priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs index 0a2c2ed67..8fa7f5745 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs @@ -136,6 +136,7 @@ async fn gateway_executes_gemini_chat_sync_same_format_via_local_finalize_respon priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -640,6 +641,7 @@ async fn gateway_executes_gemini_chat_sync_upstream_stream_via_local_finalize_re priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1134,6 +1136,7 @@ async fn gateway_executes_gemini_cli_sync_upstream_stream_via_local_finalize_res priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1642,6 +1645,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_upstream_stream_via_local_ priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs b/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs index e91578634..c3cae80b1 100644 --- a/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs +++ b/apps/aether-gateway/src/tests/ai_execute/lifecycle.rs @@ -111,6 +111,7 @@ fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs b/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs index cdb8346cb..3f901976d 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream/decision.rs @@ -131,6 +131,7 @@ async fn gateway_executes_openai_chat_stream_via_local_decision_gate_without_exe priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -535,6 +536,7 @@ async fn gateway_executes_openai_chat_stream_via_local_openai_responses_cross_fo priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1020,6 +1022,7 @@ async fn gateway_executes_openai_chat_stream_via_local_cross_format_gemini_candi priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1458,6 +1461,7 @@ async fn gateway_executes_openai_chat_stream_with_custom_path_via_local_decision priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1961,6 +1965,7 @@ async fn gateway_retries_next_local_openai_chat_stream_candidate_after_retryable priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/image.rs b/apps/aether-gateway/src/tests/ai_execute/stream/image.rs index 3ab8b0422..8ede08204 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream/image.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream/image.rs @@ -131,6 +131,7 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -517,6 +518,7 @@ async fn gateway_bridges_codex_image_sync_json_to_streaming_image_sse_impl() { priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -840,6 +842,7 @@ fn image_bridge_candidate_row( priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(false), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs b/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs index c6cfabde1..3f3b276dc 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream/pii_redaction.rs @@ -150,6 +150,7 @@ fn candidate_row() -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec!["endpoint-ai-execute-stream-pii-redaction".to_string()]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs b/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs index c7a0a8841..2d7207c5a 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs @@ -123,6 +123,7 @@ async fn gateway_executes_openai_responses_compact_as_unary_request_impl() { priority: 1, api_formats: Some(vec!["openai:responses:compact".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs b/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs index d885cc479..6842b23b9 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_cli/direct.rs @@ -136,6 +136,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs index 06566661f..68ec79da7 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider.rs @@ -182,6 +182,7 @@ async fn gateway_executes_kiro_claude_cli_stream_via_local_provider_catalog_cand priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -700,6 +701,7 @@ async fn gateway_executes_claude_cli_stream_via_local_decision_gate_without_wait priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1161,6 +1163,7 @@ async fn gateway_executes_claude_code_cli_stream_via_local_decision_gate_with_lo priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1694,6 +1697,7 @@ async fn gateway_executes_claude_chat_stream_via_local_decision_gate_with_local_ priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs index a3852bde8..3b2a21f91 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs @@ -101,6 +101,7 @@ async fn gateway_executes_gemini_chat_stream_via_local_decision_gate_with_local_ priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs index 5e2b73e5e..7b7d98ad1 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs @@ -101,6 +101,7 @@ async fn gateway_executes_gemini_cli_stream_via_local_decision_gate_with_local_s priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -553,6 +554,7 @@ async fn gateway_executes_gemini_cli_stream_via_local_decision_gate_after_oauth_ priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1108,6 +1110,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_stream_via_local_decision_gate_wi priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1573,6 +1576,7 @@ async fn gateway_executes_antigravity_gemini_cli_stream_via_local_decision_gate_ priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs index eda10935b..cc8de1774 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs @@ -91,6 +91,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -296,6 +297,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidate_selection_repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ @@ -497,6 +499,7 @@ async fn gateway_surfaces_local_execution_runtime_miss_reason_when_all_openai_ch priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -775,6 +778,7 @@ async fn gateway_retries_next_local_openai_chat_sync_candidate_after_auth_failur priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs index 2bd34498a..9d92d1fd6 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs @@ -150,6 +150,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec!["endpoint-redaction-1".to_string()]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -454,6 +455,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -647,6 +649,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let candidate_selection_repository = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ @@ -839,6 +842,7 @@ async fn gateway_executes_openai_chat_sync_with_regex_model_mapping_in_execution priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1130,6 +1134,7 @@ async fn gateway_executes_openai_chat_sync_via_local_cross_format_gemini_candida priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1151,6 +1156,7 @@ async fn gateway_executes_openai_chat_sync_via_local_cross_format_gemini_candida priority: 2, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]); row } @@ -1698,6 +1704,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_cli_syn priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2102,6 +2109,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_gemini_cli_syn priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2531,6 +2539,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_claude_sync_fa priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2939,6 +2948,7 @@ async fn gateway_returns_openai_chat_error_for_local_cross_format_gemini_sync_fa priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -3387,6 +3397,7 @@ async fn gateway_executes_openai_chat_sync_with_custom_path_via_local_decision_g priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs b/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs index 537f7c120..ad5f6866b 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/chat/pii_redaction.rs @@ -113,6 +113,7 @@ fn candidate_row(test_id: &str) -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: Some(vec![format!("endpoint-{test_id}")]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs index c365a7d36..e5d38946a 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs @@ -129,6 +129,7 @@ async fn gateway_executes_claude_code_cli_sync_via_local_decision_gate_with_loca priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs index 6239a7a1c..16a3db78a 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs @@ -197,6 +197,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -835,6 +836,7 @@ async fn gateway_executes_kiro_claude_cli_sync_via_local_provider_catalog_candid priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs index f5f11be5b..733c60669 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs @@ -124,6 +124,7 @@ async fn gateway_executes_claude_chat_sync_via_local_decision_gate_with_local_sy priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -694,6 +695,7 @@ async fn gateway_returns_claude_chat_error_for_local_sync_failure_impl() { priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs index eabb52d58..4687c1aa4 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs @@ -124,6 +124,7 @@ async fn gateway_executes_claude_cli_sync_via_local_decision_gate_with_local_syn priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -570,6 +571,7 @@ async fn gateway_returns_claude_cli_error_for_local_sync_failure_impl() { priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -853,6 +855,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs index 7c8f16999..b15c80c62 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/cli.rs @@ -141,6 +141,7 @@ async fn gateway_executes_openai_responses_sync_via_local_decision_gate_with_loc priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -650,6 +651,7 @@ async fn gateway_waits_for_api_key_concurrency_slot_then_executes_openai_respons priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1042,6 +1044,7 @@ async fn gateway_executes_openai_responses_sync_after_api_key_concurrency_wait_b priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1412,6 +1415,7 @@ async fn gateway_returns_openai_responses_error_for_local_sync_failure_impl() { priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1725,6 +1729,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_gemini_cl priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2131,6 +2136,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_sy priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2516,6 +2522,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_claude_ch priority: 1, api_formats: Some(vec!["claude:messages".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -2904,6 +2911,7 @@ async fn gateway_returns_openai_responses_error_for_local_cross_format_gemini_ch priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -3301,6 +3309,7 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs index 90034fe5c..770bd5846 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs @@ -123,6 +123,7 @@ async fn gateway_executes_gemini_cli_sync_via_local_decision_gate_with_local_syn priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -561,6 +562,7 @@ async fn gateway_returns_gemini_cli_error_for_local_sync_failure_impl() { priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -864,6 +866,7 @@ async fn gateway_executes_gemini_cli_sync_via_local_decision_gate_after_oauth_re priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1426,6 +1429,7 @@ async fn gateway_executes_vertex_ai_gemini_cli_sync_via_local_decision_gate_with priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1878,6 +1882,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_via_local_decision_gate_af priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs index 0e60c4ccf..79d89436e 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs @@ -123,6 +123,7 @@ async fn gateway_executes_gemini_chat_sync_via_local_decision_gate_with_local_sy priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -551,6 +552,7 @@ async fn gateway_returns_gemini_chat_error_for_local_sync_failure_impl() { priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs index 915de228e..cd65a8a9b 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/image.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/image.rs @@ -129,6 +129,7 @@ async fn gateway_converts_openai_image_sync_to_gemini_image_provider_impl() { priority: 1, api_formats: Some(vec!["gemini:generate_content".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -481,6 +482,7 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() { priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -833,6 +835,7 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1242,6 +1245,7 @@ async fn gateway_plans_chatgpt_web_image_sync_with_internal_web_executor_url_imp priority: 1, api_formats: Some(vec!["openai:image".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs b/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs index 5137d1dc3..45e786726 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/pii_redaction_formats.rs @@ -608,6 +608,7 @@ fn candidate_row(case: &RedactionFormatCase) -> StoredMinimalCandidateSelectionR priority: 1, api_formats: Some(vec![case.provider_format.api_format().to_string()]), endpoint_ids: Some(vec![format!("endpoint-{}", case.test_id)]), + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs index eb1de4c43..fd11355bd 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/search.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/search.rs @@ -119,6 +119,7 @@ async fn gateway_executes_codex_search_with_responses_permission_and_search_cont priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(false), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/audit.rs b/apps/aether-gateway/src/tests/audit.rs index 497a329c9..776a0203e 100644 --- a/apps/aether-gateway/src/tests/audit.rs +++ b/apps/aether-gateway/src/tests/audit.rs @@ -112,6 +112,7 @@ fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/files/mod.rs b/apps/aether-gateway/src/tests/files/mod.rs index 5c85a232a..bc374ba78 100644 --- a/apps/aether-gateway/src/tests/files/mod.rs +++ b/apps/aether-gateway/src/tests/files/mod.rs @@ -115,6 +115,7 @@ fn sample_files_candidate_row() -> StoredMinimalCandidateSelectionRow { priority: 1, api_formats: Some(vec!["gemini:files".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/frontdoor.rs b/apps/aether-gateway/src/tests/frontdoor.rs index 11a59093e..15ae3a645 100644 --- a/apps/aether-gateway/src/tests/frontdoor.rs +++ b/apps/aether-gateway/src/tests/frontdoor.rs @@ -291,6 +291,7 @@ fn sample_models_candidate_row( priority: 1, api_formats: Some(vec![api_format.to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/frontdoor/ai.rs b/apps/aether-gateway/src/tests/frontdoor/ai.rs index 7e4951a61..2ddf5b6ab 100644 --- a/apps/aether-gateway/src/tests/frontdoor/ai.rs +++ b/apps/aether-gateway/src/tests/frontdoor/ai.rs @@ -74,6 +74,7 @@ fn sample_codex_models_candidate_row( priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }, ]); row diff --git a/apps/aether-gateway/src/tests/usage.rs b/apps/aether-gateway/src/tests/usage.rs index b5c78b0a8..77e865e61 100644 --- a/apps/aether-gateway/src/tests/usage.rs +++ b/apps/aether-gateway/src/tests/usage.rs @@ -101,6 +101,7 @@ pub(super) fn sample_local_openai_candidate_row() -> StoredMinimalCandidateSelec priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/usage/local.rs b/apps/aether-gateway/src/tests/usage/local.rs index 37135c491..ea8a63ec4 100644 --- a/apps/aether-gateway/src/tests/usage/local.rs +++ b/apps/aether-gateway/src/tests/usage/local.rs @@ -1611,6 +1611,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, @@ -1926,6 +1927,7 @@ fn gateway_keeps_failed_usage_request_capture_lightweight_for_large_local_claude priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/usage/pricing.rs b/apps/aether-gateway/src/tests/usage/pricing.rs index d277521a3..6a6986599 100644 --- a/apps/aether-gateway/src/tests/usage/pricing.rs +++ b/apps/aether-gateway/src/tests/usage/pricing.rs @@ -268,6 +268,7 @@ fn sample_candidate_row(spec: ProviderSpec) -> StoredMinimalCandidateSelectionRo priority: 1, api_formats: Some(vec![spec.api_format.to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(true), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/video/gemini_sync_create.rs b/apps/aether-gateway/src/tests/video/gemini_sync_create.rs index ae96d6242..3a6db5bd8 100644 --- a/apps/aether-gateway/src/tests/video/gemini_sync_create.rs +++ b/apps/aether-gateway/src/tests/video/gemini_sync_create.rs @@ -110,6 +110,7 @@ async fn gateway_executes_gemini_video_create_via_local_decision_gate_with_local priority: 1, api_formats: Some(vec!["gemini:video".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(false), model_is_active: true, diff --git a/apps/aether-gateway/src/tests/video/openai_sync_create.rs b/apps/aether-gateway/src/tests/video/openai_sync_create.rs index a15e0ef1a..536c1939a 100644 --- a/apps/aether-gateway/src/tests/video/openai_sync_create.rs +++ b/apps/aether-gateway/src/tests/video/openai_sync_create.rs @@ -115,6 +115,7 @@ async fn gateway_executes_openai_video_create_via_local_decision_gate_with_local priority: 1, api_formats: Some(vec!["openai:video".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: Some(false), model_is_active: true, diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 5ac2f2cb0..36ff451c5 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -1188,6 +1188,7 @@ fn admin_usage_active_request_json( let mut value = json!({ "id": item.id, "status": item.status, + "request_type": item.request_type, "input_tokens": item.input_tokens, "effective_input_tokens": admin_usage_effective_input_tokens(item), "output_tokens": item.output_tokens, @@ -1304,6 +1305,7 @@ pub fn admin_usage_record_json( "status_code": item.status_code, "error_message": item.error_message, "status": item.status, + "request_type": item.request_type, "has_fallback": admin_usage_has_fallback(item), "has_retry": false, "has_rectified": false, diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 16e73a0c7..7fca6658e 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -8,6 +8,34 @@ pub mod stream; const TOOL_ERROR_PREFIX: &str = "[tool error]"; +/// Semantic operation carried by an OpenAI Responses request that asks the +/// service to compact a thread. The request still uses the Responses wire +/// contract and transport endpoint. +pub const OPENAI_RESPONSES_OPERATION_COMPACT: &str = "compact"; + +/// Resolves the operation expressed by an OpenAI Responses wire request. +/// +/// `responses_compaction_v2` is represented by a `compaction_trigger` input +/// item on the normal Responses request. The legacy Compact API format is +/// retained as the same operation for observability and scoped model mapping. +pub fn openai_responses_request_operation(api_format: &str, body: &Value) -> Option<&'static str> { + if aether_ai_formats::is_openai_responses_compact_format(api_format) { + return Some(OPENAI_RESPONSES_OPERATION_COMPACT); + } + if !aether_ai_formats::is_openai_responses_format(api_format) { + return None; + } + + body.get("input") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some("compaction_trigger")) + }) + .then_some(OPENAI_RESPONSES_OPERATION_COMPACT) +} + fn encode_tool_result_error(output: Value, is_error: bool) -> Value { if !is_error { return output; @@ -23,3 +51,41 @@ fn encode_tool_result_error(output: Value, is_error: bool) -> Value { Value::String(format!("{TOOL_ERROR_PREFIX}\n{detail}")) } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT}; + + #[test] + fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() { + assert_eq!( + openai_responses_request_operation( + "openai:responses", + &json!({ + "input": [ + {"role": "user", "content": "keep working"}, + {"type": "compaction_trigger"} + ] + }), + ), + Some(OPENAI_RESPONSES_OPERATION_COMPACT) + ); + assert_eq!( + openai_responses_request_operation( + "openai:responses", + &json!({"input": [{"role": "user", "content": "keep working"}]}), + ), + None + ); + } + + #[test] + fn resolves_legacy_compact_contract_without_a_body_marker() { + assert_eq!( + openai_responses_request_operation("openai:responses:compact", &json!({})), + Some(OPENAI_RESPONSES_OPERATION_COMPACT) + ); + } +} diff --git a/crates/aether-ai/formats/src/lib.rs b/crates/aether-ai/formats/src/lib.rs index b5ce22cf9..892943480 100644 --- a/crates/aether-ai/formats/src/lib.rs +++ b/crates/aether-ai/formats/src/lib.rs @@ -49,6 +49,9 @@ pub use formats::openai::responses::codex::{ pub use formats::openai::responses::request::{ validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation, }; +pub use formats::openai::responses::{ + openai_responses_request_operation, OPENAI_RESPONSES_OPERATION_COMPACT, +}; pub use formats::registry::{ build_stream_transcoder, convert_request, convert_request_pure, convert_request_pure_with_context, convert_response, convert_response_pure, emit_request_pure, diff --git a/crates/aether-data/adapters/mysql/src/candidate_selection.rs b/crates/aether-data/adapters/mysql/src/candidate_selection.rs index 692771ff0..97248ac5c 100644 --- a/crates/aether-data/adapters/mysql/src/candidate_selection.rs +++ b/crates/aether-data/adapters/mysql/src/candidate_selection.rs @@ -678,6 +678,7 @@ fn parse_embedded_provider_model_mappings( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }])) } @@ -698,6 +699,7 @@ fn parse_provider_model_mappings_array( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }); } _ => {} @@ -742,6 +744,11 @@ fn parse_provider_model_mapping_object_lenient( object.get("endpoint_ids").cloned(), "models.provider_model_mappings.endpoint_ids", )?; + let operations = parse_string_list( + object.get("operations").cloned(), + "models.provider_model_mappings.operations", + )? + .and_then(normalize_request_operations); Ok(Some(StoredProviderModelMapping { name: name.to_string(), @@ -752,9 +759,19 @@ fn parse_provider_model_mapping_object_lenient( })?, api_formats, endpoint_ids, + operations, })) } +fn normalize_request_operations(values: Vec) -> Option> { + let operations = values + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + (!operations.is_empty()).then_some(operations) +} + fn api_format_aliases(api_format: &str) -> Vec { aether_ai_formats::api_format_storage_aliases(api_format) } diff --git a/crates/aether-data/adapters/postgres/src/candidate_selection.rs b/crates/aether-data/adapters/postgres/src/candidate_selection.rs index 62654d6f1..e27284e72 100644 --- a/crates/aether-data/adapters/postgres/src/candidate_selection.rs +++ b/crates/aether-data/adapters/postgres/src/candidate_selection.rs @@ -1352,6 +1352,7 @@ fn parse_embedded_provider_model_mappings( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }])) } @@ -1374,6 +1375,7 @@ fn parse_provider_model_mappings_array( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }); } } @@ -1430,6 +1432,11 @@ fn parse_provider_model_mapping_object_lenient( object.get("endpoint_ids").cloned(), "models.provider_model_mappings.endpoint_ids", )?; + let operations = parse_string_list( + object.get("operations").cloned(), + "models.provider_model_mappings.operations", + )? + .and_then(normalize_request_operations); Ok(Some(StoredProviderModelMapping { name: name.to_string(), @@ -1440,9 +1447,19 @@ fn parse_provider_model_mapping_object_lenient( })?, api_formats, endpoint_ids, + operations, })) } +fn normalize_request_operations(values: Vec) -> Option> { + let operations = values + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + (!operations.is_empty()).then_some(operations) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -1626,7 +1643,7 @@ mod tests { #[test] fn parse_provider_model_mappings_accepts_stringified_array() { let parsed = parse_provider_model_mappings(Some(json!( - "[{\"name\":\"gpt-5.2\",\"priority\":2,\"api_formats\":[\"openai:chat\"]}]" + "[{\"name\":\"gpt-5.2\",\"priority\":2,\"api_formats\":[\"openai:chat\"],\"operations\":[\"COMPACT\"]}]" ))) .expect("stringified provider_model_mappings should parse"); @@ -1637,6 +1654,7 @@ mod tests { priority: 2, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: Some(vec!["compact".to_string()]), }]) ); } @@ -1653,6 +1671,7 @@ mod tests { priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }]) ); } @@ -1676,12 +1695,14 @@ mod tests { priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }, StoredProviderModelMapping { name: "gpt-5.2-mini".to_string(), priority: 1, api_formats: None, endpoint_ids: None, + operations: None, } ]) ); diff --git a/crates/aether-data/adapters/sqlite/src/candidate_selection.rs b/crates/aether-data/adapters/sqlite/src/candidate_selection.rs index 6df61b52c..ad5e749ac 100644 --- a/crates/aether-data/adapters/sqlite/src/candidate_selection.rs +++ b/crates/aether-data/adapters/sqlite/src/candidate_selection.rs @@ -1071,6 +1071,7 @@ fn parse_embedded_provider_model_mappings( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }])) } @@ -1091,6 +1092,7 @@ fn parse_provider_model_mappings_array( priority: 1, api_formats: None, endpoint_ids: None, + operations: None, }); } _ => {} @@ -1135,6 +1137,11 @@ fn parse_provider_model_mapping_object_lenient( object.get("endpoint_ids").cloned(), "models.provider_model_mappings.endpoint_ids", )?; + let operations = parse_string_list( + object.get("operations").cloned(), + "models.provider_model_mappings.operations", + )? + .and_then(normalize_request_operations); Ok(Some(StoredProviderModelMapping { name: name.to_string(), @@ -1145,9 +1152,19 @@ fn parse_provider_model_mapping_object_lenient( })?, api_formats, endpoint_ids, + operations, })) } +fn normalize_request_operations(values: Vec) -> Option> { + let operations = values + .into_iter() + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect::>(); + (!operations.is_empty()).then_some(operations) +} + fn api_format_aliases(api_format: &str) -> Vec { aether_ai_formats::api_format_storage_aliases(api_format) } @@ -1212,6 +1229,14 @@ mod tests { Some(vec!["alias-global".to_string()]) ); assert_eq!(rows[1].global_model_supports_streaming, Some(true)); + assert_eq!( + rows[1] + .model_provider_model_mappings + .as_ref() + .and_then(|mappings| mappings.first()) + .and_then(|mapping| mapping.operations.as_ref()), + Some(&vec!["compact".to_string()]) + ); let requested = repository .list_for_exact_api_format_and_requested_model_page( @@ -1393,7 +1418,7 @@ INSERT INTO models ( ) VALUES ( 'model-1', 'provider-1', 'global-1', 'provider-model', - '[{"name":"alias-provider","api_formats":["openai:chat"],"priority":1}]', + '[{"name":"alias-provider","api_formats":["openai:chat"],"operations":["COMPACT"],"priority":1}]', 1, 1, 1, 1, 1 ), ( diff --git a/crates/aether-data/contracts/src/repository/candidate_selection/types.rs b/crates/aether-data/contracts/src/repository/candidate_selection/types.rs index 309773285..46f160b6c 100644 --- a/crates/aether-data/contracts/src/repository/candidate_selection/types.rs +++ b/crates/aether-data/contracts/src/repository/candidate_selection/types.rs @@ -7,6 +7,10 @@ pub struct StoredProviderModelMapping { pub api_formats: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint_ids: Option>, + /// Optional request-operation scope. An omitted scope applies to every + /// operation supported by the selected API format. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operations: Option>, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] diff --git a/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs b/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs index 4b136bba4..25b811606 100644 --- a/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs +++ b/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs @@ -426,6 +426,7 @@ mod tests { priority: 0, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ mapped, @@ -458,6 +459,7 @@ mod tests { priority: 0, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]); let mut responses = search.clone(); @@ -521,6 +523,7 @@ mod tests { priority: 1, api_formats: None, endpoint_ids: Some(vec!["endpoint-openai".to_string()]), + operations: None, }]); let mut scoped_out = selected.clone(); diff --git a/crates/aether-scheduler-core/src/candidate/enumeration.rs b/crates/aether-scheduler-core/src/candidate/enumeration.rs index 07472e545..892bed4aa 100644 --- a/crates/aether-scheduler-core/src/candidate/enumeration.rs +++ b/crates/aether-scheduler-core/src/candidate/enumeration.rs @@ -27,6 +27,7 @@ fn enumerate_minimal_candidate_selection_inner( let EnumerateMinimalCandidateSelectionInput { rows, normalized_api_format, + request_operation, requested_model_name, resolved_global_model_name, require_streaming, @@ -63,11 +64,12 @@ fn enumerate_minimal_candidate_selection_inner( continue; } let Some((selected_provider_model_name, mapping_matched_model)) = - crate::resolve_provider_model_name_with_model_directives( + crate::resolve_provider_model_name_with_model_directives_and_request_operation( &row, requested_model_name, normalized_api_format, enable_model_directives, + request_operation, ) else { continue; diff --git a/crates/aether-scheduler-core/src/candidate/mod.rs b/crates/aether-scheduler-core/src/candidate/mod.rs index 579102284..d27642b5b 100644 --- a/crates/aether-scheduler-core/src/candidate/mod.rs +++ b/crates/aether-scheduler-core/src/candidate/mod.rs @@ -71,6 +71,7 @@ mod tests { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]), model_supports_streaming: None, model_is_active: true, @@ -199,6 +200,7 @@ mod tests { super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows: vec![sample_row("1"), disallowed], normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-5", resolved_global_model_name: "gpt-5", require_streaming: false, @@ -221,6 +223,7 @@ mod tests { super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows: vec![row], normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-5", resolved_global_model_name: "gpt-5", require_streaming: false, @@ -244,6 +247,7 @@ mod tests { super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows: vec![later_priority, earlier_priority], normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-5", resolved_global_model_name: "gpt-5", require_streaming: false, @@ -295,6 +299,7 @@ mod tests { super::enumerate_minimal_candidate_selection(EnumerateMinimalCandidateSelectionInput { rows: vec![missing_capability, matching_capability], normalized_api_format: "openai:chat", + request_operation: None, requested_model_name: "gpt-5", resolved_global_model_name: "gpt-5", require_streaming: false, diff --git a/crates/aether-scheduler-core/src/candidate/types.rs b/crates/aether-scheduler-core/src/candidate/types.rs index dc6191e08..7aaff708b 100644 --- a/crates/aether-scheduler-core/src/candidate/types.rs +++ b/crates/aether-scheduler-core/src/candidate/types.rs @@ -32,6 +32,7 @@ pub struct SchedulerMinimalCandidateSelectionCandidate { pub struct EnumerateMinimalCandidateSelectionInput<'a> { pub rows: Vec, pub normalized_api_format: &'a str, + pub request_operation: Option<&'a str>, pub requested_model_name: &'a str, pub resolved_global_model_name: &'a str, pub require_streaming: bool, diff --git a/crates/aether-scheduler-core/src/lib.rs b/crates/aether-scheduler-core/src/lib.rs index ae6b26159..3fbfe0498 100644 --- a/crates/aether-scheduler-core/src/lib.rs +++ b/crates/aether-scheduler-core/src/lib.rs @@ -40,10 +40,13 @@ 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_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, + resolve_provider_model_name_with_model_directives, + resolve_provider_model_name_with_model_directives_and_request_operation, + resolve_requested_global_model_name, resolve_requested_global_model_name_with_model_directives, + resolve_requested_global_model_name_with_model_directives_and_request_operation, + row_supports_requested_model, row_supports_requested_model_with_model_directives, + row_supports_requested_model_with_model_directives_and_request_operation, + row_supports_required_capability, select_provider_model_name, }; pub use provider::{build_provider_concurrent_limit_map, should_skip_provider_quota}; pub use ranking::{ diff --git a/crates/aether-scheduler-core/src/model.rs b/crates/aether-scheduler-core/src/model.rs index f143645f2..90f2f4bbc 100644 --- a/crates/aether-scheduler-core/src/model.rs +++ b/crates/aether-scheduler-core/src/model.rs @@ -25,17 +25,33 @@ pub fn resolve_requested_global_model_name_with_model_directives( requested_model_name: &str, api_format: &str, enable_model_directives: bool, +) -> Option { + resolve_requested_global_model_name_with_model_directives_and_request_operation( + rows, + requested_model_name, + api_format, + enable_model_directives, + None, + ) +} + +pub fn resolve_requested_global_model_name_with_model_directives_and_request_operation( + rows: &[StoredMinimalCandidateSelectionRow], + requested_model_name: &str, + api_format: &str, + enable_model_directives: bool, + request_operation: Option<&str>, ) -> Option { 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_has_available_provider_model(row, api_format) + row_has_available_provider_model(row, api_format, request_operation) && row.global_model_name == requested_model_name }) .or_else(|| { resolve_global_model_name_by(rows, |row| { - row_default_provider_model_name_available(row, api_format) + row_default_provider_model_name_available(row, api_format, request_operation) && row.model_provider_model_name == requested_model_name }) }) @@ -45,7 +61,7 @@ pub fn resolve_requested_global_model_name_with_model_directives( .as_ref() .is_some_and(|mappings| { mappings.iter().any(|mapping| { - mapping_scope_matches(mapping, row, api_format) + mapping_scope_matches(mapping, row, api_format, request_operation) && mapping.name == requested_model_name }) }) @@ -53,7 +69,7 @@ pub fn resolve_requested_global_model_name_with_model_directives( }) .or_else(|| { resolve_global_model_name_by(rows, |row| { - row_has_available_provider_model(row, api_format) + row_has_available_provider_model(row, api_format, request_operation) && row.global_model_mappings.as_ref().is_some_and(|patterns| { patterns .iter() @@ -78,10 +94,31 @@ pub fn row_supports_requested_model_with_model_directives( requested_model_name: &str, api_format: &str, enable_model_directives: bool, +) -> bool { + row_supports_requested_model_with_model_directives_and_request_operation( + row, + requested_model_name, + api_format, + enable_model_directives, + None, + ) +} + +pub fn row_supports_requested_model_with_model_directives_and_request_operation( + row: &StoredMinimalCandidateSelectionRow, + requested_model_name: &str, + api_format: &str, + enable_model_directives: bool, + request_operation: Option<&str>, ) -> 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) + row_supports_requested_model_exact( + row, + requested_model_name.as_ref(), + api_format, + request_operation, + ) }, ) } @@ -90,10 +127,11 @@ fn row_supports_requested_model_exact( row: &StoredMinimalCandidateSelectionRow, requested_model_name: &str, api_format: &str, + request_operation: Option<&str>, ) -> bool { - row_has_available_provider_model(row, api_format) + row_has_available_provider_model(row, api_format, request_operation) && (row.global_model_name == requested_model_name - || (row_default_provider_model_name_available(row, api_format) + || (row_default_provider_model_name_available(row, api_format, request_operation) && row.model_provider_model_name == requested_model_name) || row.global_model_mappings.as_ref().is_some_and(|patterns| { patterns @@ -105,7 +143,7 @@ fn row_supports_requested_model_exact( .as_ref() .is_some_and(|mappings| { mappings.iter().any(|mapping| { - mapping_scope_matches(mapping, row, api_format) + mapping_scope_matches(mapping, row, api_format, request_operation) && mapping.name == requested_model_name }) }) @@ -145,7 +183,24 @@ pub fn resolve_provider_model_name_with_model_directives( api_format: &str, enable_model_directives: bool, ) -> Option<(String, Option)> { - let selected_provider_model_name = resolve_selected_provider_model_name(row, api_format)?; + resolve_provider_model_name_with_model_directives_and_request_operation( + row, + requested_model_name, + api_format, + enable_model_directives, + None, + ) +} + +pub fn resolve_provider_model_name_with_model_directives_and_request_operation( + row: &StoredMinimalCandidateSelectionRow, + requested_model_name: &str, + api_format: &str, + enable_model_directives: bool, + request_operation: Option<&str>, +) -> Option<(String, Option)> { + let selected_provider_model_name = + resolve_selected_provider_model_name(row, api_format, request_operation)?; let Some(key_allowed_models) = row.key_allowed_models.as_ref() else { return Some((selected_provider_model_name, None)); }; @@ -175,7 +230,7 @@ pub fn resolve_provider_model_name_with_model_directives( sorted_allowed_models.sort_unstable(); for &allowed_model in &sorted_allowed_models { - if row_has_candidate_model_name(row, api_format, allowed_model) { + if row_has_candidate_model_name(row, api_format, request_operation, allowed_model) { let allowed_model = allowed_model.to_owned(); return Some((selected_provider_model_name.clone(), Some(allowed_model))); } @@ -198,13 +253,14 @@ pub fn select_provider_model_name( row: &StoredMinimalCandidateSelectionRow, api_format: &str, ) -> String { - resolve_selected_provider_model_name(row, api_format) + resolve_selected_provider_model_name(row, api_format, None) .unwrap_or_else(|| row.model_provider_model_name.clone()) } fn resolve_selected_provider_model_name( row: &StoredMinimalCandidateSelectionRow, api_format: &str, + request_operation: Option<&str>, ) -> Option { let Some(mappings) = row.model_provider_model_mappings.as_ref() else { return Some(row.model_provider_model_name.clone()); @@ -212,17 +268,20 @@ fn resolve_selected_provider_model_name( if let Some(mapping) = mappings .iter() - .filter(|mapping| mapping_scope_matches(mapping, row, api_format)) + .filter(|mapping| mapping_scope_matches(mapping, row, api_format, request_operation)) .min_by(|left, right| { left.priority .cmp(&right.priority) + .then_with(|| { + mapping_operation_scope_rank(right).cmp(&mapping_operation_scope_rank(left)) + }) .then(left.name.cmp(&right.name)) }) { return Some(mapping.name.clone()); } - row_default_provider_model_name_available(row, api_format) + row_default_provider_model_name_available(row, api_format, request_operation) .then(|| row.model_provider_model_name.clone()) } @@ -231,12 +290,12 @@ pub fn candidate_model_names( api_format: &str, ) -> BTreeSet { let mut names = BTreeSet::new(); - if row_default_provider_model_name_available(row, api_format) { + if row_default_provider_model_name_available(row, api_format, None) { names.insert(row.model_provider_model_name.clone()); } if let Some(mappings) = row.model_provider_model_mappings.as_ref() { for mapping in mappings { - if mapping_scope_matches(mapping, row, api_format) { + if mapping_scope_matches(mapping, row, api_format, None) { names.insert(mapping.name.clone()); } } @@ -247,13 +306,15 @@ pub fn candidate_model_names( fn row_has_available_provider_model( row: &StoredMinimalCandidateSelectionRow, api_format: &str, + request_operation: Option<&str>, ) -> bool { - resolve_selected_provider_model_name(row, api_format).is_some() + resolve_selected_provider_model_name(row, api_format, request_operation).is_some() } fn row_default_provider_model_name_available( row: &StoredMinimalCandidateSelectionRow, api_format: &str, + request_operation: Option<&str>, ) -> bool { let Some(mappings) = row.model_provider_model_mappings.as_ref() else { return true; @@ -264,7 +325,7 @@ fn row_default_provider_model_name_available( continue; } has_explicit_default_mapping = true; - if mapping_scope_matches(mapping, row, api_format) { + if mapping_scope_matches(mapping, row, api_format, request_operation) { return true; } } @@ -275,6 +336,7 @@ fn mapping_scope_matches( mapping: &StoredProviderModelMapping, row: &StoredMinimalCandidateSelectionRow, api_format: &str, + request_operation: Option<&str>, ) -> bool { let api_format_matches_scope = mapping.api_formats.as_ref().is_none_or(|api_formats| { api_formats @@ -285,13 +347,28 @@ fn mapping_scope_matches( return false; } - mapping.endpoint_ids.as_ref().is_none_or(|endpoint_ids| { + let endpoint_matches_scope = mapping.endpoint_ids.as_ref().is_none_or(|endpoint_ids| { endpoint_ids .iter() .any(|endpoint_id| endpoint_id == &row.endpoint_id) + }); + if !endpoint_matches_scope { + return false; + } + + mapping.operations.as_ref().is_none_or(|operations| { + request_operation.is_some_and(|request_operation| { + operations + .iter() + .any(|operation| operation.eq_ignore_ascii_case(request_operation)) + }) }) } +fn mapping_operation_scope_rank(mapping: &StoredProviderModelMapping) -> u8 { + u8::from(mapping.operations.is_some()) +} + pub fn row_supports_required_capability( row: &StoredMinimalCandidateSelectionRow, required_capability: &str, @@ -401,16 +478,18 @@ pub fn normalize_api_format(value: &str) -> String { fn row_has_candidate_model_name( row: &StoredMinimalCandidateSelectionRow, api_format: &str, + request_operation: Option<&str>, model_name: &str, ) -> bool { - (row_default_provider_model_name_available(row, api_format) + (row_default_provider_model_name_available(row, api_format, request_operation) && row.model_provider_model_name == model_name) || row .model_provider_model_mappings .as_ref() .is_some_and(|mappings| { mappings.iter().any(|mapping| { - mapping_scope_matches(mapping, row, api_format) && mapping.name == model_name + mapping_scope_matches(mapping, row, api_format, request_operation) + && mapping.name == model_name }) }) } @@ -484,6 +563,7 @@ mod tests { use super::{ matches_model_mapping, resolve_provider_model_name, resolve_provider_model_name_with_model_directives, + resolve_provider_model_name_with_model_directives_and_request_operation, resolve_requested_global_model_name_with_model_directives, row_supports_requested_model, row_supports_requested_model_with_model_directives, }; @@ -518,6 +598,7 @@ mod tests { priority: 1, api_formats: Some(vec!["openai:chat".to_string()]), endpoint_ids: None, + operations: None, }]); let resolved = resolve_provider_model_name(&row, "gpt-5", "openai:chat") @@ -581,6 +662,7 @@ mod tests { priority: 1, api_formats: Some(vec!["openai:responses".to_string()]), endpoint_ids: None, + operations: None, }]); assert!(row_supports_requested_model( @@ -599,6 +681,7 @@ mod tests { priority: 1, api_formats: Some(vec!["openai:search".to_string()]), endpoint_ids: None, + operations: None, }]); assert!(!row_supports_requested_model( &row, @@ -607,6 +690,51 @@ mod tests { )); } + #[test] + fn operation_scoped_mapping_overrides_generic_mapping_for_compaction() { + let mut row = sample_row("gpt-5.6-sol", "gpt-5.6-sol"); + row.endpoint_api_format = "openai:responses".to_string(); + row.model_provider_model_mappings = Some(vec![ + StoredProviderModelMapping { + name: "gpt-5.6-sol".to_string(), + priority: 1, + api_formats: Some(vec!["openai:responses".to_string()]), + endpoint_ids: None, + operations: None, + }, + StoredProviderModelMapping { + name: "gpt-5.6-terra".to_string(), + priority: 1, + api_formats: Some(vec!["openai:responses".to_string()]), + endpoint_ids: None, + operations: Some(vec!["compact".to_string()]), + }, + ]); + + assert_eq!( + resolve_provider_model_name_with_model_directives_and_request_operation( + &row, + "gpt-5.6-sol", + "openai:responses", + false, + None, + ) + .map(|resolved| resolved.0), + Some("gpt-5.6-sol".to_string()) + ); + assert_eq!( + resolve_provider_model_name_with_model_directives_and_request_operation( + &row, + "gpt-5.6-sol", + "openai:responses", + false, + Some("compact"), + ) + .map(|resolved| resolved.0), + Some("gpt-5.6-terra".to_string()) + ); + } + #[test] fn model_directive_suffix_prefers_exact_model_before_base_fallback() { let exact = sample_row("gpt-5.4-high", "gpt-5.4-high-upstream"); @@ -685,6 +813,7 @@ mod tests { priority: 1, api_formats: None, endpoint_ids: Some(vec!["endpoint-openai".to_string()]), + operations: None, }]); assert!(!row_supports_requested_model( diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index aa6e572a5..f2ab5ca93 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -254,8 +254,13 @@ pub fn build_lifecycle_usage_seed( let model = context_string(context, "model") .or_else(|| non_empty_str(plan.model_name.as_deref())) .unwrap_or_else(|| "unknown".to_string()); - let request_type = - infer_request_type_from_contracts(api_format.as_deref(), endpoint_api_format.as_deref()); + let provider_request = context_body_value(context, "provider_request_body") + .or_else(|| plan_json_body_capture_for_usage(plan)); + let request_type = infer_request_type_from_contracts( + api_format.as_deref(), + endpoint_api_format.as_deref(), + provider_request.as_ref(), + ); let api_family = api_format .as_deref() .and_then(infer_api_family) @@ -804,6 +809,7 @@ pub fn build_terminal_usage_context_seed( let request_type = infer_request_type_from_contracts( Some(client_contract.as_str()), Some(provider_contract.as_str()), + request_capture.provider_request.as_ref(), ); let has_format_conversion = resolve_has_format_conversion( context, @@ -1697,6 +1703,7 @@ fn build_usage_event_data_seed_with_detail( let request_type = Some(infer_request_type_from_contracts( api_format.as_deref(), endpoint_api_format.as_deref(), + request_capture.provider_request.as_ref(), )); let api_family = api_format .as_deref() @@ -2467,7 +2474,20 @@ fn infer_request_type(api_format: Option<&str>) -> String { fn infer_request_type_from_contracts( client_api_format: Option<&str>, provider_api_format: Option<&str>, + provider_request: Option<&Value>, ) -> String { + let empty_body = Value::Null; + let provider_request = provider_request.unwrap_or(&empty_body); + for api_format in [provider_api_format, client_api_format] + .into_iter() + .flatten() + { + if let Some(operation) = + aether_ai_formats::openai_responses_request_operation(api_format, provider_request) + { + return operation.to_string(); + } + } if matches!( infer_endpoint_kind(provider_api_format.unwrap_or_default()), Some("image") @@ -3711,7 +3731,9 @@ mod tests { "candidate_id": "cand-pending-event-1", "candidate_index": 3, "original_request_body": {"messages": [{"content": "omit me"}]}, - "provider_request_body": {"input": "omit me too"} + "provider_request_body": { + "input": [{"type": "compaction_trigger"}] + } })), ), 1_700_000_020, @@ -3723,6 +3745,7 @@ mod tests { assert_eq!(record.request_id, "req-pending-event-1"); assert_eq!(record.status, "pending"); assert_eq!(record.billing_status, "pending"); + assert_eq!(record.request_type.as_deref(), Some("compact")); assert_eq!(record.finalized_at_unix_secs, None); assert_eq!(record.updated_at_unix_secs, 1_700_000_020); assert!(record.request_body.is_none());