Merge pull request #675 from fawney19/fix/pr-669-tail

feat(codex): complete PR #669 protocol follow-up
This commit is contained in:
fawney19
2026-07-16 08:54:29 +08:00
committed by GitHub
83 changed files with 984 additions and 74 deletions
@@ -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,
@@ -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<String>,
require_streaming: bool,
required_capabilities: Option<serde_json::Value>,
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::<Vec<_>>();
@@ -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<dyn MinimalCandidateSelectionReadRepository> =
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,
@@ -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,
@@ -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(),
@@ -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,
@@ -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<LocalOpenAiResponsesCandidateAttempt>, 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,
@@ -53,13 +53,45 @@ impl<'a> PlannerAppState<'a> {
Vec<SchedulerSkippedCandidate>,
),
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<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SchedulerSkippedCandidate>,
),
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?;
@@ -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,
};
+31
View File
@@ -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,
@@ -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()]);
@@ -454,6 +454,25 @@ fn classifies_admin_codex_reset_credit_consume_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn admin_codex_reset_credit_consume_buffers_idempotency_key_body() {
let headers = headers(&[]);
let uri: Uri = "/api/admin/endpoints/keys/key-codex/codex-reset-credit/consume"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
.expect("decision should resolve");
let context = GatewayPublicRequestContext::from_request_parts(
"trace-codex-reset-credit-consume",
&http::Method::POST,
&uri,
&headers,
Some(decision),
);
assert!(local_proxy_route_requires_buffered_body(&context));
}
#[test]
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
let headers = headers(&[]);
@@ -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<Vec<SchedulerMinimalCandidateSelectionCandidate>, 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<Vec<SchedulerMinimalCandidateSelectionCandidate>, 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,
+2
View File
@@ -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,
@@ -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<String>) -> Option<Vec<String>> {
let operations = values
.into_iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
(!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,
}
}
@@ -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<serde_json::Value>,
) -> Option<serde_json::Value> {
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",
)?)
@@ -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,
@@ -221,6 +221,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
| (Some("endpoints_manage"), http::Method::POST, Some("create_endpoint"))
| (Some("endpoints_manage"), http::Method::POST, Some("batch_delete_keys"))
| (Some("endpoints_manage"), http::Method::POST, Some("refresh_quota"))
| (
Some("endpoints_manage"),
http::Method::POST,
Some("codex_reset_credit_consume"),
)
| (Some("endpoints_manage"), http::Method::PUT, Some("update_key"))
| (Some("endpoints_manage"), http::Method::PUT, Some("update_endpoint"))
| (Some("modules_manage"), http::Method::PUT, Some("set_enabled"))
@@ -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<Vec<SchedulerMinimalCandidateSelectionCandidate>, 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()))
@@ -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<SchedulerMinimalCandidateSelectionCandidate>,
Vec<SchedulerSkippedCandidate>,
),
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 &=
@@ -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<SchedulerMinimalCandidateSelectionCandidate>,
@@ -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(
@@ -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,
@@ -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![
@@ -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}));
@@ -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,
+1
View File
@@ -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(),
+1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -1,4 +1,4 @@
use super::{collect_workspace_rust_files, read_workspace_file};
use super::{collect_workspace_rust_files, read_workspace_file, workspace_file_exists};
fn assert_manifest_excludes(manifest_path: &str, forbidden: &[&str]) {
let manifest = read_workspace_file(manifest_path);
@@ -297,8 +297,9 @@ fn frontdoor_owns_bounded_request_body_buffering() {
#[test]
fn benchmark_binaries_are_outside_the_reusable_testkit() {
let testkit_bin = "crates/aether-testing/testkit/src/bin";
assert!(
collect_workspace_rust_files("crates/aether-testing/testkit/src/bin").is_empty(),
!workspace_file_exists(testkit_bin) || collect_workspace_rust_files(testkit_bin).is_empty(),
"aether-testkit must not own benchmark binaries"
);
assert!(
+1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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
+1
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -1024,6 +1024,74 @@ mod tests {
);
}
#[test]
fn claude_request_to_responses_encodes_error_tool_results_in_output() {
let body = json!({
"model": "claude-sonnet",
"messages": [{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_error_string",
"content": "lookup failed",
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_error_empty",
"content": "",
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_error_object",
"content": {"code": "ENOENT"},
"is_error": true
},
{
"type": "tool_result",
"tool_use_id": "toolu_error_image",
"content": [
{"type": "text", "text": "preview failed"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "AAAA"
}
}
],
"is_error": true
}
]
}],
"max_tokens": 128,
});
let converted = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect("responses request");
let input = converted["input"].as_array().expect("responses input");
assert_eq!(input[0]["output"], "[tool error]\nlookup failed");
assert_eq!(input[1]["output"], "[tool error]");
assert_eq!(input[2]["output"], "[tool error]\n{\"code\":\"ENOENT\"}");
assert_eq!(input[3]["output"], "[tool error]\npreview failed");
assert_eq!(input[4]["role"], "user");
assert_eq!(input[4]["content"][0]["type"], "input_image");
assert_eq!(
input[4]["content"][0]["image_url"],
"data:image/png;base64,AAAA"
);
assert!(input.iter().all(|item| item.get("is_error").is_none()));
}
#[test]
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
let body = json!({
+28 -2
View File
@@ -170,7 +170,11 @@ pub fn api_format_permission_covers(allowed_value: &str, requested_api_format: &
!allowed_value.is_empty()
&& !requested_api_format.is_empty()
&& (allowed_value == requested_api_format
|| allowed_value == "openai:responses" && requested_api_format == "openai:search")
|| allowed_value == "openai:responses"
&& matches!(
requested_api_format.as_str(),
"openai:responses:compact" | "openai:search"
))
}
pub fn intersect_api_format_allowed_lists(left: &[String], right: &[String]) -> Vec<String> {
@@ -269,11 +273,15 @@ mod tests {
}
#[test]
fn responses_permission_covers_only_its_search_companion() {
fn responses_permission_covers_its_companion_endpoints() {
assert!(api_format_permission_covers(
"OPENAI:RESPONSES",
"openai:search"
));
assert!(api_format_permission_covers(
"OPENAI:RESPONSES",
"openai:responses:compact"
));
assert!(api_format_permission_covers(
"openai:search",
"openai:search"
@@ -282,6 +290,10 @@ mod tests {
"openai:search",
"openai:responses"
));
assert!(!api_format_permission_covers(
"openai:responses:compact",
"openai:responses"
));
assert!(!api_format_permission_covers(
"openai:responses",
"openai:chat"
@@ -290,6 +302,13 @@ mod tests {
api_format_permission_storage_aliases("openai:search"),
vec!["openai:search".to_string(), "openai:responses".to_string()]
);
assert_eq!(
api_format_permission_storage_aliases("openai:responses:compact"),
vec![
"openai:responses:compact".to_string(),
"openai:responses".to_string(),
]
);
assert_eq!(
api_format_permission_storage_aliases("openai:responses"),
vec!["openai:responses".to_string()]
@@ -357,6 +376,13 @@ mod tests {
),
vec!["openai:search".to_string()]
);
assert_eq!(
intersect_api_format_allowed_lists(
&["openai:responses".to_string()],
&["openai:responses:compact".to_string()],
),
vec!["openai:responses:compact".to_string()]
);
assert!(intersect_api_format_allowed_lists(
&["openai:search".to_string()],
&["openai:chat".to_string()],
@@ -1,5 +1,91 @@
use serde_json::Value;
pub mod codex;
pub mod request;
pub mod response;
pub mod spec;
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;
}
let detail = match output {
Value::String(text) => text,
Value::Null => String::new(),
value => serde_json::to_string(&value).unwrap_or_else(|_| value.to_string()),
};
if detail.is_empty() {
Value::String(TOOL_ERROR_PREFIX.to_string())
} else {
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)
);
}
}
@@ -2,6 +2,8 @@ use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
use super::encode_tool_result_error;
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
@@ -449,6 +451,7 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
let (tool_output, extra_user_content) = responses_tool_result_payload(
output.as_ref(),
content_text.as_deref(),
*is_error,
extensions,
)?;
let call_id =
@@ -464,9 +467,6 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
);
item.insert("call_id".to_string(), Value::String(call_id));
item.insert("output".to_string(), tool_output);
if *is_error {
item.insert("is_error".to_string(), Value::Bool(true));
}
let extension_fields =
openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
@@ -1133,18 +1133,19 @@ fn canonical_tool_choice_to_responses(
fn responses_tool_result_payload(
output: Option<&Value>,
content_text: Option<&str>,
is_error: bool,
extensions: &BTreeMap<String, Value>,
) -> Option<(Value, Vec<Value>)> {
if let Some(Value::Array(parts)) = output {
if is_claude_tool_result(extensions) {
return claude_tool_result_parts_to_responses_payload(parts);
return claude_tool_result_parts_to_responses_payload(parts, is_error);
}
if let Some(output) = openai_chat_tool_result_parts_to_responses_output(parts) {
return Some((output, Vec::new()));
return Some((encode_tool_result_error(output, is_error), Vec::new()));
}
}
Some((
responses_tool_result_output(output, content_text),
responses_tool_result_output(output, content_text, is_error),
Vec::new(),
))
}
@@ -1260,14 +1261,22 @@ fn openai_chat_tool_result_fallback_part(part: &Value) -> Value {
})
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
fn responses_tool_result_output(
output: Option<&Value>,
content_text: Option<&str>,
is_error: bool,
) -> Value {
let text = match output {
Some(Value::String(text)) => text.clone(),
Some(Value::Null) => String::new(),
Some(value) => serde_json::to_string(value).unwrap_or_default(),
None => content_text.unwrap_or_default().to_string(),
};
Value::String(non_empty_responses_tool_output(&text))
let output = encode_tool_result_error(Value::String(text), is_error);
match output {
Value::String(text) => Value::String(non_empty_responses_tool_output(&text)),
output => output,
}
}
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
@@ -1322,7 +1331,10 @@ fn claude_document_block_is_openai_responses_representable(block: &Map<String, V
}
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Value, Vec<Value>)> {
fn claude_tool_result_parts_to_responses_payload(
parts: &[Value],
is_error: bool,
) -> Option<(Value, Vec<Value>)> {
let mut output_texts = Vec::new();
let mut extra_user_content = Vec::new();
@@ -1365,10 +1377,12 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Val
}
}
Some((
Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
extra_user_content,
))
let output = encode_tool_result_error(Value::String(output_texts.join("\n\n")), is_error);
let output = match output {
Value::String(text) => Value::String(non_empty_responses_tool_output(&text)),
output => output,
};
Some((output, extra_user_content))
}
fn claude_image_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
@@ -1661,6 +1675,37 @@ mod tests {
assert_eq!(body["input"][0]["output"], "(empty)");
}
#[test]
fn responses_request_encodes_tool_errors_for_regular_and_compact_requests() {
let request = CanonicalRequest {
model: "gpt-5.6-sol".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_error".to_string(),
name: None,
output: Some(json!("command failed")),
content_text: None,
is_error: true,
extensions: Default::default(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
for compact in [false, true] {
let body =
to_raw(&request, "gpt-5.6-sol", false, compact).expect("Responses request body");
let item = &body["input"][0];
assert_eq!(item["type"], "function_call_output");
assert_eq!(item["call_id"], "call_error");
assert_eq!(item["output"], "[tool error]\ncommand failed");
assert!(item.get("is_error").is_none());
}
}
#[test]
fn responses_request_replaces_empty_tool_call_identifiers() {
let request = CanonicalRequest {
@@ -5,6 +5,8 @@ use std::{
use serde_json::{json, Map, Value};
use super::encode_tool_result_error;
use crate::{
formats::context::FormatContext,
protocol::canonical::{
@@ -270,13 +272,13 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
item.insert("call_id".to_string(), Value::String(tool_use_id.clone()));
item.insert(
"output".to_string(),
result_output
.clone()
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
encode_tool_result_error(
result_output.clone().unwrap_or_else(|| {
Value::String(content_text.clone().unwrap_or_default())
}),
*is_error,
),
);
if *is_error {
item.insert("is_error".to_string(), Value::Bool(true));
}
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
output.push(Value::Object(item));
@@ -618,6 +620,34 @@ mod tests {
assert_eq!(body["conversation"]["id"], "conv_123");
}
#[test]
fn responses_response_builder_encodes_tool_errors_in_output() {
let response = CanonicalResponse {
id: "resp_tool_error".to_string(),
model: "gpt-5.6-sol".to_string(),
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_error".to_string(),
name: None,
output: Some(json!("command failed")),
content_text: None,
is_error: true,
extensions: BTreeMap::new(),
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
let item = &body["output"][0];
assert_eq!(item["type"], "function_call_output");
assert_eq!(item["call_id"], "call_error");
assert_eq!(item["output"], "[tool error]\ncommand failed");
assert!(item.get("is_error").is_none());
}
#[test]
fn compact_response_builder_emits_the_compaction_resource_shape() {
let mut extensions = BTreeMap::new();
+3
View File
@@ -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,
@@ -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<String>) -> Option<Vec<String>> {
let operations = values
.into_iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
(!operations.is_empty()).then_some(operations)
}
fn api_format_aliases(api_format: &str) -> Vec<String> {
aether_ai_formats::api_format_storage_aliases(api_format)
}
@@ -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<String>) -> Option<Vec<String>> {
let operations = values
.into_iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
(!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,
}
])
);
@@ -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<String>) -> Option<Vec<String>> {
let operations = values
.into_iter()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
(!operations.is_empty()).then_some(operations)
}
fn api_format_aliases(api_format: &str) -> Vec<String> {
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
),
(
@@ -827,7 +827,7 @@ mod tests {
};
#[test]
fn api_format_policy_intersection_preserves_search_companion_scope() {
fn api_format_policy_intersection_preserves_companion_scope() {
assert_eq!(
aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:search".to_string()],
@@ -842,6 +842,20 @@ mod tests {
),
vec!["openai:search".to_string()]
);
assert_eq!(
aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:responses".to_string()],
&["openai:responses:compact".to_string()],
),
vec!["openai:responses:compact".to_string()]
);
assert_eq!(
aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:responses:compact".to_string()],
&["openai:responses".to_string()],
),
vec!["openai:responses:compact".to_string()]
);
assert!(aether_ai_formats::intersect_api_format_allowed_lists(
&["openai:search".to_string()],
&["openai:chat".to_string()],
@@ -7,6 +7,10 @@ pub struct StoredProviderModelMapping {
pub api_formats: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint_ids: Option<Vec<String>>,
/// 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<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -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();
@@ -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;
@@ -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,
@@ -32,6 +32,7 @@ pub struct SchedulerMinimalCandidateSelectionCandidate {
pub struct EnumerateMinimalCandidateSelectionInput<'a> {
pub rows: Vec<StoredMinimalCandidateSelectionRow>,
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,
+7 -4
View File
@@ -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::{
+149 -20
View File
@@ -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<String> {
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<String> {
requested_model_name_candidates(requested_model_name, enable_model_directives).find_map(
|requested_model_name| {
let requested_model_name = requested_model_name.as_ref();
resolve_global_model_name_by(rows, |row| {
row_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<String>)> {
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<String>)> {
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<String> {
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<String> {
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(
+26 -3
View File
@@ -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());