feat(openai): align GPT-5.6 and Codex request contracts

This commit is contained in:
MMEXA
2026-07-11 07:40:12 +08:00
parent bc1da3bf3f
commit dfa121dd5b
178 changed files with 17947 additions and 3541 deletions
@@ -31,6 +31,7 @@ fn test_decision() -> GatewayControlDecision {
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -1166,7 +1167,7 @@ fn local_finalize_handles_openai_responses_compact_cross_format_sync_response()
assert_eq!(report.report_kind, "openai_responses_compact_sync_success");
assert_eq!(
report.client_body_json.expect("client body should exist")["object"],
"response"
"response.compaction"
);
}
@@ -1222,7 +1223,7 @@ fn local_finalize_handles_openai_responses_compact_cross_format_function_call_re
.background_report
.expect("compact tool-call should downgrade to success report");
let client_body = report.client_body_json.expect("client body should exist");
assert_eq!(client_body["object"], "response");
assert_eq!(client_body["object"], "response.compaction");
assert_eq!(client_body["output"][1]["type"], "function_call");
}
@@ -1841,6 +1842,7 @@ fn local_finalize_handles_claude_chat_cross_format_sync_response_from_openai_cha
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
},
&payload,
)
@@ -1908,6 +1910,7 @@ fn local_finalize_handles_gemini_cli_cross_format_sync_response_from_claude_cli(
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
},
&payload,
)
+11 -10
View File
@@ -48,16 +48,17 @@ pub(crate) use self::planner::{
build_standard_family_stream_plan_and_reports, build_standard_family_sync_attempt_source,
build_standard_family_sync_plan_and_reports, build_standard_stream_plan_from_decision,
build_standard_sync_plan_from_decision, candidate_auth_channel_skip_reason,
extract_pool_sticky_session_token, maybe_build_stream_decision_payload,
maybe_build_stream_plan_payload, maybe_build_sync_decision_payload,
maybe_build_sync_plan_payload, planner_is_matching_stream_request, provider_key_pool_score_id,
provider_key_pool_score_scope, read_candidate_transport_snapshot,
record_local_runtime_candidate_skip_reason,
set_local_openai_chat_execution_exhausted_diagnostic,
set_local_openai_image_execution_exhausted_diagnostic, CandidateFailureDiagnostic,
CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate, GatewayAuthApiKeySnapshot,
GatewayProviderTransportSnapshot, LocalExecutionAttemptSource, LocalExecutionCandidateKind,
LocalResolvedOAuthRequestAuth, PlannerAppState, SkippedLocalExecutionCandidate,
codex_model_capabilities_for_transport, extract_pool_sticky_session_token,
maybe_build_stream_decision_payload, maybe_build_stream_plan_payload,
maybe_build_sync_decision_payload, maybe_build_sync_plan_payload,
planner_is_matching_stream_request, provider_key_pool_score_id, provider_key_pool_score_scope,
read_candidate_transport_snapshot, record_local_runtime_candidate_skip_reason,
resolve_upstream_is_stream_for_provider, set_local_openai_chat_execution_exhausted_diagnostic,
set_local_openai_image_execution_exhausted_diagnostic, validate_final_openai_provider_request,
CandidateFailureDiagnostic, CandidateFailureDiagnosticKind, EligibleLocalExecutionCandidate,
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalExecutionAttemptSource,
LocalExecutionCandidateKind, LocalResolvedOAuthRequestAuth, PlannerAppState,
SkippedLocalExecutionCandidate,
};
pub(crate) use self::pure::*;
pub(crate) use self::transport::{
@@ -702,6 +702,7 @@ pub(crate) async fn build_lazy_requested_model_execution_candidate_attempt_sourc
G,
>(
state: PlannerAppState<'a>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
trace_id: &str,
client_api_format: &str,
requested_model: &str,
@@ -730,6 +731,7 @@ where
let record_runtime_miss_diagnostic = persistence_policy.skipped.record_runtime_miss_diagnostic;
let page_cursor = LocalCandidatePreselectionPageCursor::new(
state,
model_directive_policy,
client_api_format,
requested_model,
require_streaming,
@@ -1151,6 +1153,9 @@ async fn resolve_priority_candidate_page_with_cache(
.page_cursor
.resolved_page_cache_use_api_format_alias_match(),
cursor.client_session_affinity.as_ref(),
cursor
.page_cursor
.resolved_page_cache_model_directive_policy_hash(),
cursor.resolution_mode,
);
let page_candidates_for_fallback = page_candidates.clone();
@@ -1949,6 +1954,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
selected_provider_model_name: "gpt-5".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -2177,8 +2183,11 @@ mod tests {
async fn resolved_candidate_page_cache_requires_fixed_order_or_explicit_affinity() {
let app = AppState::new().expect("state should build");
let auth_snapshot = sample_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::default();
let mut page_cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:chat",
"gpt-5",
true,
@@ -2233,6 +2242,7 @@ mod tests {
let mut page_cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:chat",
"gpt-5",
true,
@@ -2269,6 +2279,7 @@ mod tests {
);
let mut page_cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&fixed_order_app),
&model_directive_policy,
"openai:chat",
"gpt-5",
true,
@@ -134,6 +134,7 @@ mod tests {
global_model_id: "global-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
selected_provider_model_name: "gpt-5.4".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -169,6 +169,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-test".to_string(),
selected_provider_model_name: "gpt-test-upstream".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -348,6 +348,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -624,6 +625,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -643,6 +643,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "claude-sonnet".to_string(),
selected_provider_model_name: "claude-sonnet".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -64,10 +64,25 @@ struct GatewayLocalCandidatePreselectionPort<'a> {
use_api_format_alias_match: bool,
key_mode: LocalCandidatePreselectionKeyMode,
candidate_api_formats: Vec<String>,
model_directive_enabled_api_formats: BTreeSet<String>,
model_directive_routing_models: BTreeMap<String, String>,
ranking_seed: u64,
}
impl GatewayLocalCandidatePreselectionPort<'_> {
fn model_directive_base_model(&self, candidate_api_format: &str) -> Option<&str> {
self.model_directive_routing_models
.get(&crate::ai_serving::normalize_api_format_alias(
candidate_api_format,
))
.map(String::as_str)
}
fn routing_model(&self, candidate_api_format: &str) -> &str {
self.model_directive_base_model(candidate_api_format)
.unwrap_or(self.requested_model)
}
}
#[async_trait]
impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
type Candidate = SchedulerMinimalCandidateSelectionCandidate;
@@ -99,12 +114,13 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
.state
.list_selectable_candidates_with_skip_reasons(
candidate_api_format,
self.requested_model,
self.routing_model(candidate_api_format),
self.require_streaming,
self.required_capabilities,
auth_snapshot,
self.client_session_affinity,
self.ranking_seed,
false,
)
.await?;
@@ -123,16 +139,13 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
let enable_model_directives = self.model_directive_enabled_api_formats.contains(
&crate::ai_serving::normalize_api_format_alias(candidate_api_format),
);
routing_policy_allows_provider(self.routing_policy, candidate)
&& (matches_client_format
|| auth_snapshot_allows_cross_format_candidate(
self.auth_snapshot,
self.requested_model,
self.model_directive_base_model(candidate_api_format),
candidate,
enable_model_directives,
))
}
@@ -142,16 +155,13 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
let enable_model_directives = self.model_directive_enabled_api_formats.contains(
&crate::ai_serving::normalize_api_format_alias(candidate_api_format),
);
routing_policy_allows_provider(self.routing_policy, &skipped_candidate.candidate)
&& (matches_client_format
|| auth_snapshot_allows_cross_format_candidate(
self.auth_snapshot,
self.requested_model,
self.model_directive_base_model(candidate_api_format),
&skipped_candidate.candidate,
enable_model_directives,
))
}
@@ -164,9 +174,27 @@ impl AiCandidatePreselectionPort for GatewayLocalCandidatePreselectionPort<'_> {
}
}
fn resolve_model_directive_routing_models(
policy: &crate::system_features::ModelDirectivePolicySnapshot,
candidate_api_formats: &[String],
requested_model: &str,
) -> BTreeMap<String, String> {
candidate_api_formats
.iter()
.filter_map(|api_format| {
let api_format = crate::ai_serving::normalize_api_format_alias(api_format);
let resolution = policy.resolve_reasoning(&api_format, Some(requested_model));
resolution
.base_model()
.map(|base_model| (api_format, base_model.to_string()))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn preselect_local_execution_candidates_with_serving(
state: PlannerAppState<'_>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
require_streaming: bool,
@@ -190,6 +218,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
.collect::<Vec<_>>();
preselect_local_execution_candidates_for_api_formats_with_serving(
state,
model_directive_policy,
client_api_format,
requested_model,
require_streaming,
@@ -207,6 +236,7 @@ pub(crate) async fn preselect_local_execution_candidates_with_serving(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_serving(
state: PlannerAppState<'_>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
require_streaming: bool,
@@ -224,19 +254,11 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se
>,
GatewayError,
> {
let mut model_directive_enabled_api_formats = BTreeSet::new();
for api_format in &candidate_api_formats {
if crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state.app(),
api_format,
Some(requested_model),
)
.await
{
model_directive_enabled_api_formats
.insert(crate::ai_serving::normalize_api_format_alias(api_format));
}
}
let model_directive_routing_models = resolve_model_directive_routing_models(
model_directive_policy,
&candidate_api_formats,
requested_model,
);
let port = GatewayLocalCandidatePreselectionPort {
state,
client_api_format,
@@ -249,7 +271,7 @@ pub(crate) async fn preselect_local_execution_candidates_for_api_formats_with_se
use_api_format_alias_match,
key_mode,
candidate_api_formats,
model_directive_enabled_api_formats,
model_directive_routing_models,
ranking_seed: request_distribution_seed(),
};
@@ -271,7 +293,8 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
key_mode: LocalCandidatePreselectionKeyMode,
allow_priority_page_cache: bool,
candidate_api_formats: Vec<String>,
model_directive_enabled_api_formats: BTreeSet<String>,
model_directive_routing_models: BTreeMap<String, String>,
model_directive_policy_cache_key: String,
ordering_config: SchedulerOrderingConfig,
ranking_seed: u64,
priority_page_emitted: bool,
@@ -294,9 +317,23 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
}
impl<'a> LocalCandidatePreselectionPageCursor<'a> {
fn model_directive_base_model(&self, candidate_api_format: &str) -> Option<&str> {
self.model_directive_routing_models
.get(&crate::ai_serving::normalize_api_format_alias(
candidate_api_format,
))
.map(String::as_str)
}
fn routing_model(&self, candidate_api_format: &str) -> &str {
self.model_directive_base_model(candidate_api_format)
.unwrap_or(&self.requested_model)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn new(
state: PlannerAppState<'a>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
client_api_format: &str,
requested_model: &str,
require_streaming: bool,
@@ -315,19 +352,11 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
let mut model_directive_enabled_api_formats = BTreeSet::new();
for api_format in &candidate_api_formats {
if crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state.app(),
api_format,
Some(requested_model),
)
.await
{
model_directive_enabled_api_formats
.insert(crate::ai_serving::normalize_api_format_alias(api_format));
}
}
let model_directive_routing_models = resolve_model_directive_routing_models(
model_directive_policy,
&candidate_api_formats,
requested_model,
);
let ordering_config =
super::candidate_ranking::scheduler_ordering_config_for_routing_policy(
@@ -351,7 +380,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
key_mode,
allow_priority_page_cache,
candidate_api_formats,
model_directive_enabled_api_formats,
model_directive_routing_models,
model_directive_policy_cache_key: model_directive_policy.cache_key().to_string(),
ordering_config,
ranking_seed: request_distribution_seed(),
priority_page_emitted: false,
@@ -426,6 +456,10 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
self.use_api_format_alias_match
}
pub(crate) fn resolved_page_cache_model_directive_policy_hash(&self) -> &str {
&self.model_directive_policy_cache_key
}
pub(crate) fn should_cache_current_priority_resolved_page(&self) -> bool {
if !(self.priority_page_emitted
&& self.format_index == 0
@@ -491,6 +525,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
self.key_mode.cache_key_name(),
self.use_api_format_alias_match,
self.client_session_affinity.as_ref(),
&self.model_directive_policy_cache_key,
);
let cache = self.state.app().candidate_page_cache.clone();
let ttl = candidate_page_cache_ttl_from_env();
@@ -752,11 +787,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
if normalized_api_format.is_empty() {
return Ok(None);
}
let enable_model_directives = self.model_directive_enabled_api_formats.contains(
&crate::ai_serving::normalize_api_format_alias(candidate_api_format),
);
let requested_names =
requested_model_candidate_names(&self.requested_model, enable_model_directives);
let routing_model = self.routing_model(candidate_api_format).to_string();
let requested_names = requested_model_candidate_names(&routing_model, false);
let scanned = *self
.scanned_rows_by_format
.get(&normalized_api_format)
@@ -772,11 +804,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.or_insert(0);
let Some(requested_name) = requested_names.get(requested_name_index) else {
return self
.next_fallback_page_for_api_format(
candidate_api_format,
&normalized_api_format,
enable_model_directives,
)
.next_fallback_page_for_api_format(candidate_api_format, &normalized_api_format)
.await;
};
if requested_name.trim().is_empty() {
@@ -803,9 +831,9 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.read_requested_model_rows_fast_path_page_cached(
&normalized_api_format,
requested_name,
&routing_model,
offset,
limit,
enable_model_directives,
)
.await?;
self.scanned_rows_by_format.insert(
@@ -824,7 +852,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.next_fallback_page_for_api_format(
candidate_api_format,
&normalized_api_format,
enable_model_directives,
)
.await;
}
@@ -835,7 +862,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.build_page_outcome_from_rows(
candidate_api_format,
&normalized_api_format,
enable_model_directives,
page.rows,
)
.await?
@@ -849,17 +875,17 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
&self,
normalized_api_format: &str,
requested_name: &str,
routing_model: &str,
offset: u32,
limit: u32,
enable_model_directives: bool,
) -> Result<RequestedModelCandidateRowsPage, GatewayError> {
let key = CandidateRowPageCacheKey::new(
normalized_api_format,
&self.requested_model,
routing_model,
requested_name,
offset,
limit,
enable_model_directives,
false,
);
let cache = self.state.app().candidate_row_page_cache.clone();
let ttl = candidate_page_cache_ttl_from_env();
@@ -873,11 +899,11 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
let page = read_requested_model_rows_fast_path_page(
self.state.app().data.as_ref(),
normalized_api_format,
&self.requested_model,
routing_model,
requested_name,
offset,
limit,
enable_model_directives,
false,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
@@ -913,7 +939,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
&mut self,
candidate_api_format: &str,
normalized_api_format: &str,
enable_model_directives: bool,
) -> Result<
Option<
AiCandidatePreselectionOutcome<
@@ -930,6 +955,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
return Ok(None);
}
let routing_model = self.routing_model(candidate_api_format).to_string();
let rows = self
.state
.app()
@@ -941,27 +967,21 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.filter(|row| {
row_supports_requested_model_with_model_directives(
row,
&self.requested_model,
&routing_model,
normalized_api_format,
enable_model_directives,
false,
)
})
.collect::<Vec<_>>();
self.build_page_outcome_from_rows(
candidate_api_format,
normalized_api_format,
enable_model_directives,
rows,
)
.await
self.build_page_outcome_from_rows(candidate_api_format, normalized_api_format, rows)
.await
}
async fn build_page_outcome_from_rows(
&mut self,
candidate_api_format: &str,
normalized_api_format: &str,
enable_model_directives: bool,
rows: Vec<StoredMinimalCandidateSelectionRow>,
) -> Result<
Option<
@@ -984,15 +1004,16 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
if rows.is_empty() {
return Ok(None);
}
let routing_model = self.routing_model(candidate_api_format).to_string();
let resolved_global_model_name =
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,
&self.requested_model,
&routing_model,
normalized_api_format,
enable_model_directives,
false,
) else {
return Ok(None);
};
@@ -1016,22 +1037,18 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
EnumerateMinimalCandidateSelectionInput {
rows,
normalized_api_format,
requested_model_name: &self.requested_model,
requested_model_name: &routing_model,
resolved_global_model_name: resolved_global_model_name.as_str(),
require_streaming: self.require_streaming,
required_capabilities: self.required_capabilities.as_ref(),
auth_constraints: auth_constraints.as_ref(),
},
enable_model_directives,
false,
)
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let mut candidates = Vec::new();
for candidate in enumerated_candidates {
if !self.candidate_allowed_for_page(
&candidate,
candidate_api_format,
enable_model_directives,
) {
if !self.candidate_allowed_for_page(&candidate, candidate_api_format) {
continue;
}
if !self
@@ -1065,11 +1082,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.into_iter()
.map(skipped_local_execution_candidate_from_scheduler_skip)
.filter(|skipped_candidate| {
self.skipped_candidate_allowed_for_page(
skipped_candidate,
candidate_api_format,
enable_model_directives,
)
self.skipped_candidate_allowed_for_page(skipped_candidate, candidate_api_format)
})
.collect::<Vec<_>>();
@@ -1083,7 +1096,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
&self,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
candidate_api_format: &str,
enable_model_directives: bool,
) -> bool {
routing_policy_allows_provider(self.routing_policy.as_ref(), candidate)
&& (matches_client_api_format(
@@ -1093,8 +1105,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
) || auth_snapshot_allows_cross_format_candidate(
&self.auth_snapshot,
&self.requested_model,
self.model_directive_base_model(candidate_api_format),
candidate,
enable_model_directives,
))
}
@@ -1102,7 +1114,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
&self,
skipped_candidate: &SkippedLocalExecutionCandidate,
candidate_api_format: &str,
enable_model_directives: bool,
) -> bool {
routing_policy_allows_provider(self.routing_policy.as_ref(), &skipped_candidate.candidate)
&& (matches_client_api_format(
@@ -1112,8 +1123,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
) || auth_snapshot_allows_cross_format_candidate(
&self.auth_snapshot,
&self.requested_model,
self.model_directive_base_model(candidate_api_format),
&skipped_candidate.candidate,
enable_model_directives,
))
}
}
@@ -1199,8 +1210,8 @@ fn matches_client_api_format(
pub(crate) fn auth_snapshot_allows_cross_format_candidate(
auth_snapshot: &GatewayAuthApiKeySnapshot,
requested_model: &str,
requested_base_model: Option<&str>,
candidate: &SchedulerMinimalCandidateSelectionCandidate,
enable_model_directives: bool,
) -> bool {
if let Some(allowed_providers) = auth_snapshot.effective_allowed_providers() {
let provider_allowed = allowed_providers.iter().any(|value| {
@@ -1217,15 +1228,10 @@ pub(crate) fn auth_snapshot_allows_cross_format_candidate(
}
if let Some(allowed_models) = auth_snapshot.effective_allowed_models() {
let requested_base_model = enable_model_directives
.then(|| crate::ai_serving::model_directive_base_model(requested_model))
.flatten();
let model_allowed = allowed_models.iter().any(|value| {
value == requested_model
|| value == &candidate.global_model_name
|| requested_base_model
.as_ref()
.is_some_and(|base_model| value == base_model)
|| requested_base_model.is_some_and(|base_model| value == base_model)
});
if !model_allowed {
return false;
@@ -1303,8 +1309,11 @@ mod tests {
.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:chat",
"gpt-5",
true,
@@ -1545,8 +1554,11 @@ mod tests {
.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,
"claude:messages",
"gpt-5.5-xhigh",
false,
@@ -1578,6 +1590,77 @@ mod tests {
);
}
#[tokio::test]
async fn custom_policy_suffix_uses_the_same_base_model_for_candidate_selection() {
let mut row = openai_responses_mapping_row();
row.global_model_name = "deployment-alias".to_string();
row.global_model_mappings = None;
row.model_provider_model_name = "gpt-5.6-sol".to_string();
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([row]));
let data_state =
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository)
.with_system_config_values_for_tests([
(
crate::system_features::ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string(),
serde_json::json!(true),
),
(
crate::system_features::MODEL_DIRECTIVES_CONFIG_KEY.to_string(),
serde_json::json!({
"reasoning_effort": {
"api_formats": {
"openai:responses": {
"suffixes": ["VendorFuture"],
"mappings": {
"VendorFuture": {
"reasoning": { "context": "all_turns" }
}
}
}
}
}
}),
),
]);
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",
"deployment-alias-VendorFuture",
false,
None,
&auth_snapshot,
None,
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
true,
None,
)
.await;
let page = cursor
.next_page()
.await
.expect("preselection should succeed")
.expect("custom directive base model should resolve a candidate");
assert_eq!(page.candidates.len(), 1);
assert_eq!(page.candidates[0].global_model_name, "deployment-alias");
assert_eq!(
page.candidates[0].selected_provider_model_name,
"gpt-5.6-sol"
);
}
#[tokio::test]
async fn claude_request_uses_cross_format_key_when_same_provider_messages_key_lacks_model() {
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
@@ -1605,8 +1688,11 @@ mod tests {
.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,
"claude:messages",
"deepseek-v4-pro",
false,
@@ -1680,8 +1766,11 @@ mod tests {
.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,
"claude:messages",
"gpt-5",
false,
@@ -1,11 +1,12 @@
use axum::body::Bytes;
use crate::ai_serving::is_json_request;
use crate::ai_serving::{
endpoint_config_forces_upstream_stream_policy as endpoint_config_forces_upstream_stream_policy_impl,
enforce_request_body_stream_field as enforce_request_body_stream_field_impl,
force_upstream_streaming_for_provider as force_upstream_streaming_for_provider_impl,
is_json_request, parse_direct_request_body as parse_direct_request_body_impl,
resolve_upstream_is_stream_from_endpoint_config as resolve_upstream_is_stream_from_endpoint_config_impl,
parse_direct_request_body as parse_direct_request_body_impl,
resolve_format_upstream_is_stream_for_provider as resolve_upstream_is_stream_for_provider_impl,
};
pub(crate) use crate::ai_serving::{
CLAUDE_CHAT_STREAM_PLAN_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
@@ -54,10 +55,10 @@ pub(crate) fn resolve_upstream_is_stream_for_provider(
client_is_stream: bool,
hard_requires_streaming: bool,
) -> bool {
let hard_requires_streaming = hard_requires_streaming
|| force_upstream_streaming_for_provider(provider_type, provider_api_format);
resolve_upstream_is_stream_from_endpoint_config_impl(
resolve_upstream_is_stream_for_provider_impl(
endpoint_config,
provider_type,
provider_api_format,
client_is_stream,
hard_requires_streaming,
)
@@ -178,6 +179,27 @@ mod tests {
true,
false,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"codex",
"openai:image",
true,
true,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"codex",
"openai:responses:compact",
true,
true,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"custom",
"openai:responses:compact",
true,
true,
));
}
#[test]
@@ -14,7 +14,10 @@ use serde_json::{json, Value};
use tracing::warn;
use crate::ai_serving::planner::common::extract_standard_requested_model;
use crate::ai_serving::{ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::ai_serving::{
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot,
PlannerAppState,
};
use crate::client_session_affinity::client_session_affinity_from_request;
use crate::clock::current_unix_secs;
use crate::routing::{
@@ -27,12 +30,16 @@ use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AiExecutionDecision, AppState, GatewayError};
const ROUTING_GROUP_SELECTION_CACHE_TTL: Duration = Duration::from_secs(30);
const CODEX_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
const CODEX_FEDRAMP_HEADER: &str = "x-openai-fedramp";
const CODEX_RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite";
#[derive(Debug, Clone)]
pub(crate) struct ResolvedLocalDecisionAuthInput {
pub(crate) auth_context: ExecutionRuntimeAuthContext,
pub(crate) auth_snapshot: GatewayAuthApiKeySnapshot,
pub(crate) required_capabilities: Option<serde_json::Value>,
pub(crate) model_directive_policy: crate::system_features::ModelDirectivePolicySnapshot,
}
#[derive(Debug, Clone)]
@@ -46,6 +53,7 @@ pub(crate) struct LocalRequestedModelDecisionInput {
pub(crate) routing_policy: Option<ResolvedRoutingPolicy>,
pub(crate) routing_trace_seed: Option<RoutingDecisionTrace>,
pub(crate) routing_context: Option<LocalRoutingRequestContext>,
pub(crate) model_directive_policy: crate::system_features::ModelDirectivePolicySnapshot,
}
#[derive(Debug, Clone)]
@@ -86,14 +94,52 @@ impl LocalRequestedModelDecisionInput {
pub(crate) fn apply_provider_request_routing_policy_to_decision(
input: &LocalRequestedModelDecisionInput,
decision: &mut AiExecutionDecision,
transport: Option<&GatewayProviderTransportSnapshot>,
) -> Result<(), GatewayError> {
let provider_api_format = decision
.provider_api_format
.clone()
.or_else(|| {
input
.routing_context
.as_ref()
.map(|context| context.client_api_format.clone())
})
.unwrap_or_default();
let provider_type = decision.provider_type.clone().unwrap_or_default();
let terminal_provider_model = decision
.provider_request_body
.as_ref()
.and_then(|body| body.get("model"))
.and_then(Value::as_str)
.or(decision.mapped_model.as_deref())
.or(decision.model_name.as_deref())
.unwrap_or(input.requested_model.as_str());
let model_capabilities = transport.and_then(|transport| {
crate::ai_serving::codex_model_capabilities_for_transport(
transport,
provider_api_format.as_str(),
terminal_provider_model,
input.requested_model.as_str(),
)
});
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
&mut decision.provider_request_headers,
provider_type.as_str(),
provider_api_format.as_str(),
terminal_provider_model,
input.requested_model.as_str(),
model_capabilities.as_ref(),
);
let Some(context) = input.routing_context.as_ref() else {
return Ok(());
};
let provider_api_format = decision
.provider_api_format
.as_deref()
.unwrap_or(context.client_api_format.as_str());
let provider_body_rules = decision
.report_context
.as_ref()
.and_then(|context| context.get("body_rules"))
.cloned();
let resolved_model = decision
.mapped_model
.as_deref()
@@ -104,6 +150,21 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
.clone()
.unwrap_or(serde_json::Value::Null);
let mut provider_headers = btree_headers_to_header_map(&decision.provider_request_headers)?;
let mut protected_codex_header_names = vec![CODEX_ACCOUNT_ID_HEADER, CODEX_FEDRAMP_HEADER];
if provider_type.eq_ignore_ascii_case("codex")
&& crate::ai_serving::is_openai_responses_family_format(provider_api_format.as_str())
{
protected_codex_header_names.extend([
"x-client-request-id",
"accept",
"content-encoding",
CODEX_RESPONSES_LITE_HEADER,
]);
}
let protected_codex_headers = protected_codex_header_names
.into_iter()
.map(|name| (name, provider_headers.get(name).cloned()))
.collect::<Vec<_>>();
let provider_headers_json = headers_to_routing_value(&provider_headers);
let policy = resolve_gateway_routing_policy(GatewayRoutingPolicyInput {
group_id: context.group_id.as_deref(),
@@ -112,7 +173,7 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
selection_source: context.selection_source.as_str(),
requested_model: input.requested_model.as_str(),
resolved_model,
api_format: provider_api_format,
api_format: provider_api_format.as_str(),
user_id: Some(input.auth_context.user_id.as_str()),
api_key_id: Some(input.auth_context.api_key_id.as_str()),
headers: &provider_headers_json,
@@ -134,6 +195,80 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
&mut provider_headers,
&policy.mutation_plan,
)?;
for (name, value) in protected_codex_headers {
provider_headers.remove(name);
if let Some(value) = value {
provider_headers.insert(HeaderName::from_static(name), value);
}
}
if original_provider_request_body.is_some() {
let provider_model = provider_request_body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or(decision.mapped_model.as_deref())
.or(decision.model_name.as_deref())
.unwrap_or(input.requested_model.as_str())
.to_string();
let model_capabilities = transport.and_then(|transport| {
crate::ai_serving::codex_model_capabilities_for_transport(
transport,
provider_api_format.as_str(),
provider_model.as_str(),
input.requested_model.as_str(),
)
});
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: context.client_api_format.as_str(),
provider_api_format: provider_api_format.as_str(),
provider_type: provider_type.as_str(),
provider_model: provider_model.as_str(),
source_model: input.requested_model.as_str(),
body_rules: provider_body_rules.as_ref(),
upstream_is_stream: decision.upstream_is_stream,
require_body_stream_field: original_provider_request_body
.as_ref()
.is_some_and(|body| body.get("stream").is_some()),
},
model_capabilities.as_ref(),
)
.map_err(|violation| GatewayError::Client {
status: StatusCode::BAD_REQUEST,
message: format!("routing provider_request violates provider contract: {violation:?}"),
})?;
}
let provider_model = provider_request_body
.get("model")
.and_then(Value::as_str)
.or(decision.mapped_model.as_deref())
.or(decision.model_name.as_deref())
.unwrap_or(input.requested_model.as_str());
let mut provider_request_headers = header_map_to_btree_headers(&provider_headers);
let model_capabilities = transport.and_then(|transport| {
crate::ai_serving::codex_model_capabilities_for_transport(
transport,
provider_api_format.as_str(),
provider_model,
input.requested_model.as_str(),
)
});
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
&mut provider_request_headers,
provider_type.as_str(),
provider_api_format.as_str(),
provider_model,
input.requested_model.as_str(),
model_capabilities.as_ref(),
);
crate::ai_serving::apply_codex_openai_compact_terminal_headers(
&mut provider_request_headers,
provider_type.as_str(),
provider_api_format.as_str(),
);
provider_headers = btree_headers_to_header_map(&provider_request_headers)?;
decision.provider_request_headers = header_map_to_btree_headers(&provider_headers);
if original_provider_request_body.is_some() {
decision.provider_request_body = Some(provider_request_body);
@@ -145,6 +280,8 @@ pub(crate) fn apply_provider_request_routing_policy_to_decision(
struct GatewayAuthenticatedDecisionInputPort<'a> {
state: PlannerAppState<'a>,
now_unix_secs: u64,
model_directive_policy: &'a crate::system_features::ModelDirectivePolicySnapshot,
model_directive_base_model: Option<String>,
}
#[async_trait]
@@ -181,6 +318,7 @@ impl AiAuthenticatedDecisionInputPort for GatewayAuthenticatedDecisionInputPort<
&auth_context.api_key_id,
requested_model,
explicit_required_capabilities,
self.model_directive_base_model.as_deref(),
)
.await)
}
@@ -195,6 +333,7 @@ impl AiAuthenticatedDecisionInputPort for GatewayAuthenticatedDecisionInputPort<
auth_context,
auth_snapshot,
required_capabilities,
model_directive_policy: self.model_directive_policy.clone(),
}
}
}
@@ -213,6 +352,7 @@ pub(crate) fn build_local_requested_model_decision_input(
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
model_directive_policy: resolved_input.model_directive_policy,
}
}
@@ -373,12 +513,16 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
}
}
if requested_model_changed {
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(client_api_format, Some(input.requested_model.as_str()));
input.required_capabilities = PlannerAppState::new(state)
.resolve_request_candidate_required_capabilities(
&input.auth_context.user_id,
&input.auth_context.api_key_id,
Some(input.requested_model.as_str()),
input.required_capabilities.as_ref(),
model_directive_resolution.base_model(),
)
.await;
}
@@ -475,11 +619,22 @@ pub(crate) async fn resolve_local_authenticated_decision_input(
state: &AppState,
auth_context: ExecutionRuntimeAuthContext,
requested_model: Option<&str>,
requested_model_api_format: Option<&str>,
explicit_required_capabilities: Option<&serde_json::Value>,
model_directive_policy: &crate::system_features::ModelDirectivePolicySnapshot,
) -> Result<Option<ResolvedLocalDecisionAuthInput>, GatewayError> {
let model_directive_base_model = match (requested_model, requested_model_api_format) {
(Some(model), Some(api_format)) => model_directive_policy
.resolve_reasoning(api_format, Some(model))
.base_model()
.map(str::to_owned),
_ => None,
};
let port = GatewayAuthenticatedDecisionInputPort {
state: PlannerAppState::new(state),
now_unix_secs: current_unix_secs(),
model_directive_policy,
model_directive_base_model,
};
run_ai_authenticated_decision_input(
@@ -730,6 +885,10 @@ fn ensure_report_context_routing_trace(
#[cfg(test)]
mod tests {
use super::*;
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider,
};
fn sample_auth_context() -> ExecutionRuntimeAuthContext {
ExecutionRuntimeAuthContext {
@@ -782,6 +941,7 @@ mod tests {
client_session_affinity: None,
routing_policy: None,
routing_trace_seed: None,
model_directive_policy: Default::default(),
routing_context: Some(LocalRoutingRequestContext {
group_id: Some("group-1".to_string()),
group_version: Some(3),
@@ -830,6 +990,7 @@ mod tests {
request_id: Some("trace-1".to_string()),
candidate_id: Some("candidate-1".to_string()),
provider_name: Some("provider".to_string()),
provider_type: Some("openai".to_string()),
provider_id: Some("provider-1".to_string()),
endpoint_id: Some("endpoint-1".to_string()),
key_id: Some("key-1".to_string()),
@@ -869,9 +1030,80 @@ mod tests {
}
}
fn set_provider_request_rules(input: &mut LocalRequestedModelDecisionInput, actions: Value) {
fn sample_codex_transport_with_card() -> GatewayProviderTransportSnapshot {
let card = json!({
"id": "gpt-future-agent",
"slug": "gpt-future-agent",
"use_responses_lite": true,
"supports_reasoning_summaries": true,
"default_reasoning_level": "low",
"default_reasoning_summary": "none",
"supported_reasoning_levels": [{"effort": "low"}, {"effort": "high"}]
});
GatewayProviderTransportSnapshot {
provider: GatewayProviderTransportProvider {
id: "provider-codex".to_string(),
name: "Codex".to_string(),
provider_type: "codex".to_string(),
website: None,
is_active: true,
keep_priority_on_conversion: false,
enable_format_conversion: true,
concurrent_limit: None,
max_retries: None,
proxy: None,
request_timeout_secs: None,
stream_first_byte_timeout_secs: None,
config: None,
},
endpoint: GatewayProviderTransportEndpoint {
id: "endpoint-codex".to_string(),
provider_id: "provider-codex".to_string(),
api_format: "openai:responses:compact".to_string(),
api_family: Some("openai".to_string()),
endpoint_kind: Some("compact".to_string()),
is_active: true,
base_url: "https://chatgpt.com/backend-api/codex".to_string(),
header_rules: None,
body_rules: None,
max_retries: None,
custom_path: None,
config: None,
format_acceptance_config: None,
proxy: None,
},
key: GatewayProviderTransportKey {
id: "key-codex".to_string(),
provider_id: "provider-codex".to_string(),
name: "Codex key".to_string(),
auth_type: "oauth".to_string(),
is_active: true,
api_formats: Some(vec!["openai:responses:compact".to_string()]),
auth_type_by_format: None,
allow_auth_channel_mismatch_formats: None,
allowed_models: Some(vec!["gpt-future-agent".to_string()]),
capabilities: None,
rate_multipliers: None,
global_priority_by_format: None,
expires_at_unix_secs: None,
proxy: None,
fingerprint: None,
upstream_metadata: Some(crate::ai_serving::build_codex_model_catalog_metadata(&[
card,
])),
decrypted_api_key: "access-token".to_string(),
decrypted_auth_config: None,
},
}
}
fn set_provider_request_rules(
input: &mut LocalRequestedModelDecisionInput,
allowed_models: &[&str],
actions: Value,
) {
let config = json!({
"allowed_models": ["gpt-5"],
"allowed_models": allowed_models,
"rules": [{
"id": "provider-patch",
"priority": 1,
@@ -904,6 +1136,7 @@ mod tests {
client_session_affinity: None,
routing_policy: None,
routing_trace_seed: None,
model_directive_policy: Default::default(),
routing_context: Some(LocalRoutingRequestContext {
group_id: Some("stale".to_string()),
group_version: Some(1),
@@ -971,6 +1204,7 @@ mod tests {
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
model_directive_policy: Default::default(),
};
let group_config_json = json!({
"rules": [{
@@ -1006,7 +1240,7 @@ mod tests {
let input = sample_decision_input();
let mut decision = sample_decision();
apply_provider_request_routing_policy_to_decision(&input, &mut decision)
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
.expect("provider routing mutation should apply");
assert_eq!(
@@ -1035,6 +1269,179 @@ mod tests {
);
}
#[test]
fn codex_compact_contract_is_terminal_after_routing_mutations() {
let mut input = sample_decision_input();
input
.routing_context
.as_mut()
.expect("routing context")
.client_api_format = "openai:responses:compact".to_string();
set_provider_request_rules(
&mut input,
&["gpt-5"],
json!([
{
"type": "json_patch_body",
"patch": [
{"op": "add", "path": "/store", "value": true},
{"op": "add", "path": "/top_logprobs", "value": 5},
{"op": "add", "path": "/custom_extension", "value": true},
{"op": "replace", "path": "/input", "value": "routed compact input"},
{"op": "replace", "path": "/tools", "value": [{
"type": "function",
"name": "lookup",
"cache_control": {"type": "ephemeral"}
}]}
]
},
{
"type": "patch_headers",
"patch": [
{"op": "set", "name": "chatgpt-account-id", "value": "spoofed"},
{"op": "set", "name": "x-openai-fedramp", "value": "false"},
{"op": "set", "name": "x-client-request-id", "value": "spoofed"},
{"op": "set", "name": "accept", "value": "text/event-stream"},
{"op": "set", "name": "content-encoding", "value": "zstd"}
]
}
]),
);
let mut decision = sample_decision();
decision.provider_type = Some("codex".to_string());
decision.provider_api_format = Some("openai:responses:compact".to_string());
decision.client_api_format = Some("openai:responses:compact".to_string());
decision.provider_request_body = Some(json!({
"model": "gpt-5",
"input": [],
"tools": [{"type": "function", "name": "lookup"}]
}));
decision
.provider_request_headers
.insert(CODEX_ACCOUNT_ID_HEADER.to_string(), "account-1".to_string());
decision
.provider_request_headers
.insert(CODEX_FEDRAMP_HEADER.to_string(), "true".to_string());
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
.expect("terminal contract should accept the projected request");
let body = decision.provider_request_body.as_ref().expect("body");
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(body["input"][0]["type"], "message");
assert_eq!(
body["input"][0]["content"][0]["text"],
"routed compact input"
);
assert!(body["tools"][0].get("cache_control").is_none());
for field in ["store", "top_logprobs", "custom_extension"] {
assert!(
body.get(field).is_none(),
"unexpected Compact field: {field}"
);
}
assert_eq!(
decision
.provider_request_headers
.get(CODEX_ACCOUNT_ID_HEADER),
Some(&"account-1".to_string())
);
assert_eq!(
decision.provider_request_headers.get(CODEX_FEDRAMP_HEADER),
Some(&"true".to_string())
);
for header in ["x-client-request-id", "accept", "content-encoding"] {
assert!(
!decision.provider_request_headers.contains_key(header),
"unexpected Compact header: {header}"
);
}
}
#[test]
fn codex_responses_lite_contract_is_terminal_after_routing_mutations() {
let mut input = sample_decision_input();
input.requested_model = "gpt-future-agent".to_string();
input
.routing_context
.as_mut()
.expect("routing context")
.client_api_format = "openai:responses:compact".to_string();
set_provider_request_rules(
&mut input,
&["gpt-future-agent"],
json!([
{
"type": "json_patch_body",
"patch": [
{"op": "replace", "path": "/input", "value": "routed compact input"},
{"op": "add", "path": "/instructions", "value": "Routed instructions"},
{"op": "replace", "path": "/tools", "value": [{
"type": "function",
"name": "lookup",
"parameters": {},
"cache_control": {"type": "ephemeral"}
}]},
{"op": "add", "path": "/parallel_tool_calls", "value": true},
{"op": "add", "path": "/reasoning", "value": {
"effort": "high",
"context": "current_turn"
}}
]
},
{
"type": "patch_headers",
"patch": [{
"op": "set",
"name": "x-openai-internal-codex-responses-lite",
"value": "false"
}]
}
]),
);
let mut decision = sample_decision();
decision.provider_type = Some("codex".to_string());
decision.provider_api_format = Some("openai:responses:compact".to_string());
decision.client_api_format = Some("openai:responses:compact".to_string());
decision.mapped_model = Some("gpt-future-agent".to_string());
decision.provider_request_body = Some(json!({
"model": "gpt-future-agent",
"input": [],
"tools": []
}));
let transport = sample_codex_transport_with_card();
apply_provider_request_routing_policy_to_decision(&input, &mut decision, Some(&transport))
.expect("terminal Lite contract should accept the projected request");
let body = decision.provider_request_body.as_ref().expect("body");
assert_eq!(body["input"][0]["type"], "additional_tools");
assert_eq!(body["input"][0]["tools"][0]["name"], "lookup");
assert!(body["input"][0]["tools"][0].get("cache_control").is_none());
assert_eq!(body["input"][1]["role"], "developer");
assert_eq!(
body["input"][1]["content"][0]["text"],
"Routed instructions"
);
assert_eq!(body["input"][2]["role"], "user");
assert_eq!(
body["input"][2]["content"][0]["text"],
"routed compact input"
);
assert!(body.get("tools").is_none());
assert!(body.get("instructions").is_none());
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(body["reasoning"]["effort"], "high");
assert_eq!(body["reasoning"]["context"], "all_turns");
assert_eq!(
decision
.provider_request_headers
.get(CODEX_RESPONSES_LITE_HEADER)
.map(String::as_str),
Some("true")
);
}
#[test]
fn provider_request_routing_policy_rejects_body_patch_without_json_body() {
let input = sample_decision_input();
@@ -1042,7 +1449,7 @@ mod tests {
decision.provider_request_body = None;
decision.provider_request_body_base64 = Some("AA==".to_string());
let error = apply_provider_request_routing_policy_to_decision(&input, &mut decision)
let error = apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
.expect_err("provider body patch should reject binary upstream bodies");
match error {
@@ -1067,6 +1474,7 @@ mod tests {
let mut input = sample_decision_input();
set_provider_request_rules(
&mut input,
&["gpt-5"],
json!([{
"type": "patch_headers",
"patch": [{
@@ -1080,7 +1488,7 @@ mod tests {
decision.provider_request_body = None;
decision.provider_request_body_base64 = Some("AA==".to_string());
apply_provider_request_routing_policy_to_decision(&input, &mut decision)
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
.expect("header-only provider routing mutation should apply without JSON body");
assert_eq!(decision.provider_request_body, None);
@@ -1112,7 +1520,7 @@ mod tests {
"priority_slot": 3
}));
apply_provider_request_routing_policy_to_decision(&input, &mut decision)
apply_provider_request_routing_policy_to_decision(&input, &mut decision, None)
.expect("provider routing mutation should seed pool trace");
let routing_trace = &decision.report_context.as_ref().unwrap()["routing_trace"];
@@ -34,6 +34,7 @@ pub(crate) use self::candidate_resolution::{
candidate_auth_channel_skip_reason, read_candidate_transport_snapshot,
EligibleLocalExecutionCandidate, LocalExecutionCandidateKind, SkippedLocalExecutionCandidate,
};
pub(crate) use self::common::resolve_upstream_is_stream_for_provider;
pub(crate) use self::passthrough::{
build_local_same_format_stream_attempt_source, build_local_same_format_stream_plan_and_reports,
build_local_same_format_sync_attempt_source, build_local_same_format_sync_plan_and_reports,
@@ -48,7 +49,7 @@ pub(crate) use self::plan_builders::{
pub(crate) use self::pool_scores::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
pub(crate) use self::request_gzip::resolve_transport_request_gzip_policy;
pub(crate) use self::request_gzip::resolve_transport_request_encoding_policy;
pub(crate) use self::route::is_matching_stream_request as planner_is_matching_stream_request;
pub(crate) use self::runtime_miss::{
apply_local_runtime_candidate_terminal_reason, record_local_runtime_candidate_skip_reason,
@@ -79,7 +80,8 @@ pub(crate) use self::standard::{
build_local_stream_plan_and_reports as build_standard_family_stream_plan_and_reports,
build_local_sync_attempt_source as build_standard_family_sync_attempt_source,
build_local_sync_plan_and_reports as build_standard_family_sync_plan_and_reports,
set_local_openai_chat_execution_exhausted_diagnostic,
codex_model_capabilities_for_transport, set_local_openai_chat_execution_exhausted_diagnostic,
validate_final_openai_provider_request,
};
pub(crate) use self::state::{
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
@@ -71,6 +71,7 @@ pub(crate) fn build_passthrough_stream_plan_from_decision(
.content_type
.take()
.or_else(|| provider_request_headers.get("content-type").cloned());
let stream = payload.upstream_is_stream;
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
@@ -84,7 +85,7 @@ pub(crate) fn build_passthrough_stream_plan_from_decision(
body_bytes_b64: None,
body_ref: None,
},
stream: true,
stream,
},
);
@@ -60,7 +60,9 @@ pub(crate) async fn resolve_local_same_format_provider_decision_input(
state,
auth_context,
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -115,15 +117,22 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
);
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(spec_metadata.api_format, Some(&input.requested_model));
let routing_model = model_directive_resolution
.base_model()
.unwrap_or(&input.requested_model);
let (candidates, preselection_skipped) = planner_state
.list_selectable_candidates_with_skip_reasons(
spec_metadata.api_format,
&input.requested_model,
routing_model,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await?;
let outcome = materialize_local_execution_candidates_with_serving(
@@ -212,15 +221,22 @@ pub(crate) async fn build_local_same_format_provider_candidate_attempt_source<'a
input.required_capabilities.as_ref(),
LocalCandidatePersistencePolicyKind::SameFormatProviderDecision,
);
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(spec_metadata.api_format, Some(&input.requested_model));
let routing_model = model_directive_resolution
.base_model()
.unwrap_or(&input.requested_model);
let (candidates, preselection_skipped) = planner_state
.list_selectable_candidates_with_skip_reasons(
spec_metadata.api_format,
&input.requested_model,
routing_model,
spec_metadata.require_streaming,
input.required_capabilities.as_ref(),
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await?;
@@ -17,7 +17,7 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_same_format_provider_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -184,7 +184,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
compatibility_edits: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -194,6 +194,7 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
request_id: trace_id.to_string(),
candidate_id: candidate_id.to_string(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -211,8 +212,8 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -221,7 +222,11 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -40,7 +40,9 @@ use super::{
LocalSameFormatProviderCandidateAttempt, LocalSameFormatProviderDecisionInput,
LocalSameFormatProviderSpec,
};
use crate::ai_serving::planner::standard::same_format_provider_request_body_failure_extra_data;
use crate::ai_serving::planner::standard::{
codex_model_capabilities_for_transport, same_format_provider_request_body_failure_extra_data,
};
pub(crate) fn resolve_same_format_provider_transport_unsupported_reason_for_trace(
transport: &GatewayProviderTransportSnapshot,
@@ -137,13 +139,26 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
else {
return Ok(None);
};
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
spec.api_format,
Some(&input.requested_model),
)
.await;
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(spec.api_format, Some(&input.requested_model));
let model_directive_mapping =
match model_directive_resolution.mapping_patch_for_mapped_model(&prepared.mapped_model) {
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let effective_headers = input.effective_headers(&parts.headers);
let redaction = resolve_provider_chat_pii_redaction(
state,
@@ -169,7 +184,7 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
prepared.force_body_stream_field,
prepared.kiro_auth.as_ref(),
prepared.is_claude_code,
enable_model_directives,
false,
)
else {
mark_skipped_local_same_format_provider_candidate_with_extra_data(
@@ -196,18 +211,11 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
};
let mut base_provider_request_body = base_provider_request.body;
let mut compatibility_edits = base_provider_request.compatibility_edits;
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
spec.api_format,
Some(&input.requested_model),
)
.await
{
if let Some(mapping) = model_directive_mapping.as_ref() {
let before_mapping = base_provider_request_body.clone();
crate::ai_serving::apply_model_directive_mapping_patch(
&mut base_provider_request_body,
&mapping,
mapping,
);
if before_mapping != base_provider_request_body {
compatibility_edits.push(SameFormatProviderCompatibilityEdit {
@@ -230,6 +238,48 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
}
}
let source_model = body_json
.get("model")
.and_then(Value::as_str)
.unwrap_or(input.requested_model.as_str());
let codex_model_capabilities = codex_model_capabilities_for_transport(
&transport,
prepared.provider_api_format.as_str(),
prepared.mapped_model.as_str(),
source_model,
);
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut base_provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec.api_format,
provider_api_format: prepared.provider_api_format.as_str(),
provider_type: transport.provider.provider_type.as_str(),
provider_model: prepared.mapped_model.as_str(),
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream: prepared.upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
body_json,
prepared.force_body_stream_field,
),
},
codex_model_capabilities.as_ref(),
)
.is_err()
{
mark_skipped_local_same_format_provider_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_missing",
)
.await;
return Ok(None);
}
let antigravity_auth = if prepared.is_antigravity {
let mut antigravity_support = classify_local_antigravity_request_support(
&transport,
@@ -467,6 +517,27 @@ pub(crate) async fn resolve_local_same_format_provider_candidate_payload_parts(
.await;
return Ok(None);
};
crate::ai_serving::apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
transport.provider.provider_type.as_str(),
prepared.provider_api_format.as_str(),
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
let provider_model = provider_request_body
.get("model")
.and_then(Value::as_str)
.unwrap_or(prepared.mapped_model.as_str());
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
&mut provider_request_headers,
transport.provider.provider_type.as_str(),
prepared.provider_api_format.as_str(),
provider_model,
source_model,
codex_model_capabilities.as_ref(),
);
request_identity_response_encoding_when_redacted(
&mut provider_request_headers,
redaction.redacted,
@@ -1,23 +1,50 @@
use aether_ai_serving::AiRequestGzipPolicy;
use serde_json::Value;
use crate::ai_serving::is_openai_responses_family_format;
use crate::ai_serving::{normalize_api_format_alias, parse_codex_auth_identity};
use super::state::GatewayProviderTransportSnapshot;
const DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES: usize = 64 * 1024;
pub(crate) fn resolve_transport_request_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
transport_request_gzip_policy_from_config(transport.endpoint.config.as_ref())
.or_else(|| transport_request_gzip_policy_from_config(transport.provider.config.as_ref()))
.or_else(|| default_transport_request_gzip_policy(transport))
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct TransportRequestEncodingPolicy {
pub content_encoding: Option<String>,
pub request_gzip: Option<AiRequestGzipPolicy>,
}
fn default_transport_request_gzip_policy(
pub(crate) fn resolve_transport_request_encoding_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
) -> TransportRequestEncodingPolicy {
if transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex")
&& normalize_api_format_alias(transport.endpoint.api_format.as_str())
== "openai:responses:compact"
{
return TransportRequestEncodingPolicy::default();
}
let request_gzip = transport_request_gzip_policy_from_config(
transport.endpoint.config.as_ref(),
)
.or_else(|| transport_request_gzip_policy_from_config(transport.provider.config.as_ref()));
if request_gzip.is_some() {
return TransportRequestEncodingPolicy {
content_encoding: None,
request_gzip,
};
}
TransportRequestEncodingPolicy {
content_encoding: default_transport_request_content_encoding(transport),
request_gzip: None,
}
}
fn default_transport_request_content_encoding(
transport: &GatewayProviderTransportSnapshot,
) -> Option<String> {
if !transport
.provider
.provider_type
@@ -26,19 +53,24 @@ fn default_transport_request_gzip_policy(
{
return None;
}
if !is_codex_request_gzip_endpoint_api_format(transport.endpoint.api_format.as_str()) {
if !is_codex_request_compression_api_format(transport.endpoint.api_format.as_str()) {
return None;
}
let auth_type =
crate::ai_serving::transport::auth::resolve_local_auth_type_for_transport_format(transport);
let uses_codex_backend = auth_type == "oauth"
|| (auth_type == "bearer"
&& parse_codex_auth_identity(transport.key.decrypted_auth_config.as_deref())
.uses_codex_backend);
if !uses_codex_backend {
return None;
}
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
Some("zstd".to_string())
}
fn is_codex_request_gzip_endpoint_api_format(api_format: &str) -> bool {
is_openai_responses_family_format(api_format)
|| api_format.trim().eq_ignore_ascii_case("openai:image")
fn is_codex_request_compression_api_format(api_format: &str) -> bool {
normalize_api_format_alias(api_format) == "openai:responses"
}
fn transport_request_gzip_policy_from_config(
@@ -216,6 +248,16 @@ mod tests {
}
}
fn resolved_gzip_policy(
transport: &GatewayProviderTransportSnapshot,
) -> Option<AiRequestGzipPolicy> {
resolve_transport_request_encoding_policy(transport).request_gzip
}
fn resolved_content_encoding(transport: &GatewayProviderTransportSnapshot) -> Option<String> {
resolve_transport_request_encoding_policy(transport).content_encoding
}
#[test]
fn endpoint_request_gzip_policy_overrides_provider_policy() {
let transport = sample_transport(
@@ -226,7 +268,7 @@ mod tests {
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
resolved_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1024),
@@ -244,7 +286,7 @@ mod tests {
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
resolved_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
@@ -265,7 +307,7 @@ mod tests {
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
resolved_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(4096),
@@ -283,7 +325,7 @@ mod tests {
);
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
resolved_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1),
@@ -292,35 +334,73 @@ mod tests {
}
#[test]
fn codex_responses_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:responses", None, None);
fn codex_responses_endpoint_uses_zstd_without_a_size_threshold() {
let mut transport = sample_transport("codex", "openai:responses", None, None);
transport.key.auth_type = "oauth".to_string();
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
resolved_content_encoding(&transport).as_deref(),
Some("zstd")
);
assert_eq!(resolved_gzip_policy(&transport), None);
}
#[test]
fn codex_image_endpoint_gets_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:image", None, None);
fn codex_responses_api_key_auth_does_not_enable_default_compression() {
let transport = sample_transport("codex", "openai:responses", None, None);
assert_eq!(resolved_content_encoding(&transport), None);
assert_eq!(resolved_gzip_policy(&transport), None);
}
#[test]
fn codex_responses_bearer_auth_uses_identity_metadata_for_backend_compression() {
let mut transport = sample_transport("codex", "openai:responses", None, None);
transport.key.auth_type = "bearer".to_string();
transport.key.decrypted_auth_config =
Some(r#"{"provider_type":"codex","account_id":"account-1"}"#.to_string());
assert_eq!(
resolve_transport_request_gzip_policy(&transport),
Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(DEFAULT_CODEX_REQUEST_GZIP_MIN_BYTES),
})
resolved_content_encoding(&transport).as_deref(),
Some("zstd")
);
assert_eq!(resolved_gzip_policy(&transport), None);
}
#[test]
fn codex_image_endpoint_does_not_get_responses_request_gzip_policy() {
let transport = sample_transport("codex", "openai:image", None, None);
assert_eq!(resolved_content_encoding(&transport), None);
assert_eq!(resolved_gzip_policy(&transport), None);
}
#[test]
fn codex_compact_endpoint_does_not_get_default_request_gzip_policy() {
let transport = sample_transport("codex", "openai:responses:compact", None, None);
assert_eq!(resolved_content_encoding(&transport), None);
assert_eq!(resolved_gzip_policy(&transport), None);
}
#[test]
fn codex_compact_endpoint_rejects_an_explicit_request_gzip_policy() {
let transport = sample_transport(
"codex",
"openai:responses:compact",
None,
Some(json!({"request_gzip": {"enabled": true, "min_bytes": 2048}})),
);
assert_eq!(resolved_gzip_policy(&transport), None);
assert_eq!(resolved_content_encoding(&transport), None);
}
#[test]
fn non_codex_endpoint_does_not_get_default_request_gzip_policy() {
let transport = sample_transport("openai", "openai:responses", None, None);
assert_eq!(resolve_transport_request_gzip_policy(&transport), None);
assert_eq!(resolved_content_encoding(&transport), None);
assert_eq!(resolved_gzip_policy(&transport), None);
}
}
@@ -77,6 +77,7 @@ mod tests {
auth_endpoint_signature: None,
execution_runtime_candidate: true,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -7,7 +7,7 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_gemini_files_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -124,7 +124,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
upstream_url,
file_name: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -134,6 +134,7 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
request_id: trace_id.to_string(),
candidate_id: candidate_id.clone(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -156,8 +157,8 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -166,6 +167,10 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -53,7 +53,9 @@ pub(super) async fn resolve_local_gemini_files_decision_input(
state,
auth_context,
None,
decision.auth_endpoint_signature.as_deref(),
Some(&explicit_required_capabilities),
&decision.model_directive_policy,
)
.await
{
@@ -5,7 +5,7 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_image_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -84,11 +84,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
serde_json::Value::Bool(true),
);
}
let upstream_is_stream = resolved
.provider_request_body
.get("stream")
.and_then(serde_json::Value::as_bool)
.unwrap_or(spec_metadata.require_streaming);
let upstream_is_stream = resolved.upstream_is_stream;
let effective_headers = input.effective_headers(&parts.headers);
let report_context = append_execution_contract_fields_to_value(
build_local_execution_report_context(LocalExecutionReportContextParts {
@@ -135,7 +131,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
spec_metadata.api_format,
provider_api_format.as_str(),
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -145,6 +141,7 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
request_id: trace_id.to_string(),
candidate_id: candidate_id.clone(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -162,8 +159,8 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
provider_request_body: Some(resolved.provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -172,6 +169,10 @@ pub(super) async fn maybe_build_local_openai_image_decision_payload_for_candidat
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -16,8 +16,8 @@ use crate::ai_serving::transport::{
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
};
use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
build_chatgpt_web_image_request_body,
apply_codex_openai_special_headers, build_chatgpt_web_image_request_body,
build_codex_openai_image_api_provider_request_body,
build_gemini_image_request_body_from_openai_image_request,
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
default_model_for_openai_image_operation, normalize_openai_image_request,
@@ -48,6 +48,7 @@ pub(super) struct LocalOpenAiImageCandidatePayloadParts {
pub(super) upstream_url: String,
pub(super) input_summary: Value,
pub(super) transport_profile: Option<ResolvedTransportProfile>,
pub(super) upstream_is_stream: bool,
}
pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
@@ -130,7 +131,10 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
parts,
body_json,
body_base64,
openai_image_normalize_options_for_provider(&transport.provider.provider_type),
openai_image_normalize_options_for_provider(
&transport.provider.provider_type,
Some(prepared_candidate.mapped_model.as_str()),
),
);
let Some(normalized_request) = normalized_request else {
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
@@ -174,29 +178,56 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
} else {
build_openai_image_upstream_url(transport, Some(parts.uri.path()), parts.uri.query())
};
let mut provider_request_body = if is_chatgpt_web {
match build_chatgpt_web_image_request_body(parts, body_json, body_base64) {
Ok(body) => body,
Err(err) => err.to_error_json(),
}
} else if is_codex || is_grok {
build_openai_image_provider_request_body(&normalized_request)
let upstream_is_stream =
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
spec_metadata.require_streaming && candidate.supports_streaming,
false,
);
let provider_request_body = if is_chatgpt_web {
Some(
match build_chatgpt_web_image_request_body(parts, body_json, body_base64) {
Ok(body) => body,
Err(err) => err.to_error_json(),
},
)
} else if is_codex {
build_codex_openai_image_api_provider_request_body(
&normalized_request,
Some(prepared_candidate.mapped_model.as_str()),
upstream_is_stream,
)
} else if is_grok {
Some(build_openai_image_provider_request_body(
&normalized_request,
))
} else {
build_openai_image_api_provider_request_body(
&normalized_request,
Some(prepared_candidate.mapped_model.as_str()),
upstream_is_stream,
)
};
if !is_chatgpt_web {
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
}
let Some(provider_request_body) = provider_request_body else {
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_missing",
CandidateFailureDiagnostic::provider_request_body_missing(
spec_metadata.api_format,
spec_metadata.api_format,
"codex_openai_images_request_contract",
),
)
.await;
return None;
};
let Some(mut provider_request_headers) = (if is_grok {
build_grok_browser_headers(GrokHeaderInput {
transport,
@@ -214,10 +245,12 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
headers: effective_headers,
auth_header: &auth_header,
auth_value: &auth_value,
accept: if is_codex || is_chatgpt_web {
"text/event-stream"
accept: if is_codex {
None
} else if upstream_is_stream {
Some("text/event-stream")
} else {
"application/json"
Some("application/json")
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
@@ -245,7 +278,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else if is_grok {
} else {
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
@@ -287,6 +320,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
upstream_url,
input_summary,
transport_profile,
upstream_is_stream,
})
}
@@ -403,7 +437,14 @@ async fn resolve_local_openai_image_to_gemini_candidate_payload_parts(
return None;
}
};
let upstream_is_stream = spec_metadata.require_streaming;
let upstream_is_stream =
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
provider_api_format,
spec_metadata.require_streaming && candidate.supports_streaming,
false,
);
let Some(upstream_url) = crate::ai_serving::planner::standard::build_standard_upstream_url(
parts,
transport,
@@ -474,6 +515,7 @@ async fn resolve_local_openai_image_to_gemini_candidate_payload_parts(
upstream_url,
input_summary: converted.summary_json,
transport_profile: None,
upstream_is_stream,
})
}
@@ -58,7 +58,9 @@ pub(super) async fn resolve_local_openai_image_decision_input(
state,
auth_context,
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -124,6 +126,7 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
matches_client_format.then_some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await
{
@@ -144,8 +147,8 @@ pub(super) async fn list_local_openai_image_candidate_attempts(
auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
None,
candidate,
false,
)
});
}
@@ -197,6 +200,7 @@ pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
matches_client_format.then_some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await
{
@@ -206,16 +210,16 @@ pub(super) async fn build_local_openai_image_candidate_attempt_source<'a>(
auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
None,
candidate,
false,
)
});
format_skipped.retain(|candidate| {
auth_snapshot_allows_cross_format_candidate(
&input.auth_snapshot,
&input.requested_model,
None,
&candidate.candidate,
false,
)
});
}
@@ -5,7 +5,7 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -104,7 +104,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
provider_request_body,
upstream_url,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: false,
@@ -114,6 +114,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
request_id: trace_id.to_string(),
candidate_id: candidate_id.clone(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -137,8 +138,8 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts: resolve_transport_execution_timeouts(&transport),
@@ -147,6 +148,10 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -62,7 +62,9 @@ pub(super) async fn resolve_local_video_create_decision_input(
state,
auth_context,
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -130,6 +132,7 @@ pub(super) async fn list_local_video_create_candidate_attempts(
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await
{
@@ -186,6 +189,7 @@ pub(super) async fn build_local_video_create_candidate_attempt_source<'a>(
Some(&input.auth_snapshot),
input.client_session_affinity.as_ref(),
current_unix_secs(),
false,
)
.await
{
@@ -3,5 +3,29 @@
mod tests;
pub(crate) use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
};
pub(crate) fn codex_model_capabilities_for_transport(
transport: &crate::ai_serving::GatewayProviderTransportSnapshot,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
) -> Option<crate::ai_serving::CodexResponsesModelCapabilities> {
if !transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex")
|| !crate::ai_serving::is_openai_responses_family_format(provider_api_format)
{
return None;
}
Some(
crate::ai_serving::resolve_codex_responses_model_capabilities(
provider_model,
source_model,
transport.key.upstream_metadata.as_ref(),
),
)
}
@@ -1,8 +1,6 @@
use std::collections::BTreeMap;
use super::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
};
use super::{apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers};
use crate::ai_serving::planner::standard::build_local_openai_responses_request_body;
use http::{HeaderMap, HeaderValue};
use serde_json::json;
@@ -10,7 +8,7 @@ use serde_json::json;
#[test]
fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
let mut body = json!({
"model": "gpt-5",
"model": "gpt-5.4",
"max_output_tokens": 128,
"temperature": 0.3,
"top_p": 0.9,
@@ -31,10 +29,11 @@ fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
assert!(body.get("top_p").is_none());
assert!(body.get("metadata").is_none());
assert_eq!(body["store"], false);
assert_eq!(body["instructions"], "");
assert!(body.get("instructions").is_none());
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
assert_eq!(body["parallel_tool_calls"], true);
assert!(body.get("reasoning").is_none());
assert_eq!(body["reasoning"]["effort"], "medium");
assert!(body["reasoning"].get("summary").is_none());
}
#[test]
@@ -80,7 +79,7 @@ fn strips_store_for_compact_even_when_body_rules_handle_it() {
{"action":"set","path":"top_p","value":0.5}
]);
let mut body = json!({
"model": "gpt-5",
"model": "gpt-5.4",
"max_output_tokens": 128,
"metadata": {"client": "desktop", "mode": "custom"},
"store": true,
@@ -99,12 +98,29 @@ fn strips_store_for_compact_even_when_body_rules_handle_it() {
assert!(body.get("max_output_tokens").is_none());
assert!(body.get("store").is_none());
assert_eq!(body["instructions"], "Keep custom");
assert_eq!(body["metadata"]["mode"], "custom");
assert_eq!(body["top_p"], 0.5);
assert!(body.get("metadata").is_none());
assert!(body.get("top_p").is_none());
assert_eq!(body["parallel_tool_calls"], true);
assert!(body.as_object().is_some_and(|object| {
object.keys().all(|field| {
matches!(
field.as_str(),
"model"
| "input"
| "instructions"
| "tools"
| "parallel_tool_calls"
| "reasoning"
| "service_tier"
| "prompt_cache_key"
| "text"
)
})
}));
}
#[test]
fn injects_stable_prompt_cache_key_for_codex_requests() {
fn does_not_synthesize_prompt_cache_key_from_api_key_identity() {
let mut body = json!({
"model": "gpt-5",
"input": "hello",
@@ -118,10 +134,7 @@ fn injects_stable_prompt_cache_key_for_codex_requests() {
Some("key-123"),
);
assert_eq!(
body["prompt_cache_key"],
"53363264-dbb0-5f9d-b9c7-3e92c45c5bdf"
);
assert!(body.get("prompt_cache_key").is_none());
}
#[test]
@@ -144,57 +157,91 @@ fn keeps_existing_prompt_cache_key_for_codex_requests() {
}
#[test]
fn injects_chatgpt_account_id_and_session_headers_for_codex_requests() {
fn injects_identity_headers_without_deriving_session_headers_from_body() {
let mut headers = BTreeMap::new();
let body = json!({
"model": "gpt-5",
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
});
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut headers,
&body,
&HeaderMap::new(),
"codex",
"openai:responses",
Some("trace-codex-123"),
Some(r#"{"account_id":"acc-123"}"#),
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acc-123".to_string())
);
assert_eq!(headers.get("x-client-request-id"), None);
assert_eq!(
headers.get("user-agent"),
Some(&"codex_cli_rs/0.144.1".to_string())
);
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
assert!(!headers.contains_key("version"));
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
assert_eq!(headers.get("session-id"), None);
assert_eq!(headers.get("thread-id"), None);
}
#[test]
fn injects_only_codex_client_headers_for_images_requests() {
let mut headers = BTreeMap::new();
apply_codex_openai_special_headers(
&mut headers,
&json!({
"model": "gpt-image-2",
"prompt": "draw a city"
}),
&HeaderMap::new(),
"codex",
"openai:image",
Some("trace-codex-image-123"),
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acc-123".to_string())
);
assert_eq!(
headers.get("x-client-request-id"),
Some(&"trace-codex-123".to_string())
);
assert_eq!(
headers.get("user-agent"),
Some(
&"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)"
.to_string()
)
);
assert_eq!(headers.get("originator"), Some(&"codex-tui".to_string()));
assert_eq!(
headers.get("session_id"),
Some(&"ab5ecce4f0d110fe".to_string())
);
assert_eq!(
headers.get("conversation_id"),
Some(&"ab5ecce4f0d110fe".to_string())
Some(&"codex_cli_rs/0.144.1".to_string())
);
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
assert!(!headers.contains_key("version"));
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
for name in ["x-client-request-id", "session-id", "thread-id"] {
assert!(
!headers.contains_key(name),
"unexpected Images header: {name}"
);
}
}
#[test]
fn respects_existing_codex_request_and_session_headers() {
fn preserves_client_context_headers_and_enforces_codex_auth_identity_headers() {
let mut headers = BTreeMap::new();
headers.insert(
"x-client-request-id".to_string(),
"kept-by-rule-request".to_string(),
);
headers.insert("session_id".to_string(), "kept-by-rule".to_string());
headers.insert("session-id".to_string(), "kept-by-rule-session".to_string());
headers.insert("thread-id".to_string(), "kept-by-rule-thread".to_string());
headers.insert(
"chatgpt-account-id".to_string(),
"configured-spoof".to_string(),
);
headers.insert(
"x-openai-fedramp".to_string(),
"configured-false".to_string(),
);
let body = json!({
"model": "gpt-5",
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
@@ -205,12 +252,12 @@ fn respects_existing_codex_request_and_session_headers() {
HeaderValue::from_static("user-specified-request"),
);
original_headers.insert(
"session_id",
"session-id",
HeaderValue::from_static("user-specified-session"),
);
original_headers.insert(
"conversation_id",
HeaderValue::from_static("user-specified-conversation"),
"thread-id",
HeaderValue::from_static("user-specified-thread"),
);
original_headers.insert(
"user-agent",
@@ -220,15 +267,21 @@ fn respects_existing_codex_request_and_session_headers() {
"originator",
HeaderValue::from_static("user-specified-originator"),
);
original_headers.insert("version", HeaderValue::from_static("user-version"));
original_headers.insert("x-openai-fedramp", HeaderValue::from_static("user-fedramp"));
original_headers.insert(
"chatgpt-account-id",
HeaderValue::from_static("user-account"),
);
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut headers,
&body,
&original_headers,
"codex",
"openai:responses",
Some("trace-codex-123"),
Some(r#"{"account_id":"acc-123"}"#),
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
);
assert_eq!(
@@ -237,47 +290,52 @@ fn respects_existing_codex_request_and_session_headers() {
);
assert!(!headers.contains_key("user-agent"));
assert!(!headers.contains_key("originator"));
assert_eq!(headers.get("session_id"), Some(&"kept-by-rule".to_string()));
assert!(!headers.contains_key("conversation_id"));
assert!(!headers.contains_key("version"));
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acc-123".to_string())
);
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
assert_eq!(
headers.get("session-id"),
Some(&"kept-by-rule-session".to_string())
);
assert_eq!(
headers.get("thread-id"),
Some(&"kept-by-rule-thread".to_string())
);
}
#[test]
fn skips_conversation_id_for_compact_codex_requests() {
fn compact_does_not_derive_session_headers_from_body() {
let mut headers = BTreeMap::new();
let body = json!({
"model": "gpt-5",
"prompt_cache_key": "172c39e6-c0a0-5a70-8b63-e0f8e0d185a3",
});
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut headers,
&body,
&HeaderMap::new(),
"codex",
"openai:responses:compact",
Some("trace-codex-compact-123"),
Some(r#"{"account_id":"acc-123"}"#),
Some(r#"{"account_id":"acc-123","is_fedramp":true}"#),
);
assert_eq!(
headers.get("chatgpt-account-id"),
Some(&"acc-123".to_string())
);
assert_eq!(
headers.get("x-client-request-id"),
Some(&"trace-codex-compact-123".to_string())
);
assert_eq!(headers.get("x-client-request-id"), None);
assert_eq!(
headers.get("user-agent"),
Some(
&"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)"
.to_string()
)
Some(&"codex_cli_rs/0.144.1".to_string())
);
assert_eq!(headers.get("originator"), Some(&"codex-tui".to_string()));
assert_eq!(
headers.get("session_id"),
Some(&"ab5ecce4f0d110fe".to_string())
);
assert!(!headers.contains_key("conversation_id"));
assert_eq!(headers.get("originator"), Some(&"codex_cli_rs".to_string()));
assert!(!headers.contains_key("version"));
assert_eq!(headers.get("x-openai-fedramp"), Some(&"true".to_string()));
assert_eq!(headers.get("session-id"), None);
assert_eq!(headers.get("thread-id"), None);
}
@@ -60,7 +60,9 @@ pub(super) async fn resolve_local_standard_decision_input(
state,
auth_context,
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -119,6 +121,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
);
let preselection = preselect_local_execution_candidates_with_serving(
planner_state,
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
false,
@@ -243,6 +246,7 @@ pub(super) async fn build_local_standard_candidate_attempt_source<'a>(
let (source, candidate_count) =
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
planner_state,
&input.model_directive_policy,
trace_id,
spec_metadata.api_format,
&input.requested_model,
@@ -338,6 +342,7 @@ async fn maybe_append_gemini_image_openai_image_preselection(
let image_preselection = preselect_local_execution_candidates_for_api_formats_with_serving(
planner_state,
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
spec_metadata.require_streaming,
@@ -15,7 +15,7 @@ use crate::ai_serving::planner::report_context::{
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::CandidateFailureDiagnostic;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -176,7 +176,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
transport_profile: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -186,6 +186,7 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
request_id: trace_id.to_string(),
candidate_id: candidate_id.to_string(),
provider_name: candidate.provider_name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -203,8 +204,8 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts,
@@ -213,7 +214,11 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -372,6 +377,7 @@ mod tests {
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
model_directive_policy: Default::default(),
}
}
@@ -475,6 +481,7 @@ mod tests {
} else {
"gpt-4o-upstream".to_string()
},
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -21,9 +21,9 @@ use crate::ai_serving::planner::redaction::{
};
use crate::ai_serving::planner::spec_metadata::local_standard_spec_metadata;
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_headers, apply_deepseek_tool_call_thinking_compat,
is_deepseek_provider, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
codex_model_capabilities_for_transport, is_deepseek_provider,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::kiro::{
build_kiro_provider_headers, build_kiro_provider_request_body,
@@ -44,7 +44,9 @@ use crate::ai_serving::transport::{
};
use crate::ai_serving::{
build_openai_image_request_body_from_gemini_image_request, gemini_request_is_image_generation,
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth,
OpenAiImageOperation,
};
use crate::{AppState, GatewayError};
@@ -313,7 +315,13 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
{
return Ok(
resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
state, parts, trace_id, body_json, input, attempt,
state,
parts,
trace_id,
body_json,
input,
attempt,
spec_metadata.require_streaming,
)
.await,
);
@@ -555,13 +563,27 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
);
let force_body_stream_field =
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await;
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
let model_directive_mapping = match model_directive_resolution
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
{
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_standard_candidate(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let redaction = resolve_provider_chat_pii_redaction(
state,
parts,
@@ -588,7 +610,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
},
Some(input.auth_context.api_key_id.as_str()),
Some(effective_headers),
enable_model_directives,
false,
) {
Some(body) => body,
None => {
@@ -655,18 +677,8 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
provider_api_format,
Some(body_json),
);
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut provider_request_body,
&mapping,
);
if let Some(mapping) = model_directive_mapping.as_ref() {
crate::ai_serving::apply_model_directive_mapping_patch(&mut provider_request_body, mapping);
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
// re-enforce stream-field policy afterward.
enforce_provider_body_stream_policy(
@@ -712,6 +724,64 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
);
}
let normalized_provider_api_format =
crate::ai_serving::normalize_api_format_alias(provider_api_format);
if matches!(
normalized_provider_api_format.as_str(),
"openai:chat" | "openai:responses" | "openai:responses:compact"
) {
let source_model = body_json
.get("model")
.and_then(Value::as_str)
.unwrap_or(input.requested_model.as_str());
let codex_model_capabilities = codex_model_capabilities_for_transport(
transport,
provider_api_format,
prepared_candidate.mapped_model.as_str(),
source_model,
);
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec_metadata.api_format,
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: prepared_candidate.mapped_model.as_str(),
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
body_json,
force_body_stream_field,
),
},
codex_model_capabilities.as_ref(),
)
.is_err()
{
mark_skipped_local_standard_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
attempt.candidate_index,
&attempt.candidate_id,
"provider_request_body_build_failed",
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"standard_family_request_finalization",
),
)
.await;
return Ok(None);
}
}
if let Some(kiro_auth) = kiro_auth.as_ref() {
return Ok(build_kiro_cross_format_payload_parts(
state,
@@ -752,8 +822,6 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
.await);
}
let normalized_provider_api_format =
crate::ai_serving::normalize_api_format_alias(provider_api_format);
if normalized_provider_api_format == "gemini:generate_content"
&& is_gemini_cli_provider_transport(transport)
{
@@ -838,7 +906,7 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
return Ok(None);
};
let mut provider_request_headers = resolved_headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
@@ -988,7 +1056,7 @@ async fn build_gemini_cli_cross_format_payload_parts(
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&resolved.body,
effective_headers,
@@ -1146,6 +1214,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
body_json: &serde_json::Value,
input: &LocalStandardDecisionInput,
attempt: &LocalStandardCandidateAttempt,
client_requires_streaming: bool,
) -> Option<LocalStandardCandidatePayloadParts> {
let client_api_format = "gemini:generate_content";
let provider_api_format = "openai:image";
@@ -1221,9 +1290,44 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
return None;
};
let upstream_is_stream = true;
let upstream_url =
build_openai_image_upstream_url(transport, Some("/v1/images/generations"), None);
let upstream_is_stream = resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
provider_api_format,
client_requires_streaming && candidate.supports_streaming,
false,
);
let is_codex = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex");
let mut provider_request_body = converted.body_json;
if upstream_is_stream {
provider_request_body
.as_object_mut()?
.insert("stream".to_string(), Value::Bool(true));
}
provider_request_body = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
converted.operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
)?;
if is_codex {
provider_request_body = project_codex_openai_image_api_request_body(
&provider_request_body,
converted.operation,
)?;
}
let request_path = match converted.operation {
OpenAiImageOperation::Generate => "/v1/images/generations",
OpenAiImageOperation::Edit => "/v1/images/edits",
};
let upstream_url = build_openai_image_upstream_url(transport, Some(request_path), None);
let effective_headers = input.effective_headers(&parts.headers);
let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
@@ -1231,9 +1335,15 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
headers: effective_headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
accept: if is_codex {
None
} else if upstream_is_stream {
Some("text/event-stream")
} else {
Some("application/json")
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &converted.body_json,
provider_request_body: &provider_request_body,
original_request_body: body_json,
})
else {
@@ -1254,9 +1364,9 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
.await;
return None;
};
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&converted.body_json,
&provider_request_body,
effective_headers,
transport.provider.provider_type.as_str(),
provider_api_format,
@@ -1269,7 +1379,7 @@ async fn resolve_local_gemini_image_to_openai_image_candidate_payload_parts(
auth_value: prepared_candidate.auth_value,
mapped_model: converted.mapped_model,
provider_api_format: provider_api_format.to_string(),
provider_request_body: converted.body_json,
provider_request_body,
provider_request_headers,
upstream_url,
upstream_is_stream,
@@ -15,7 +15,8 @@ mod normalize;
mod openai;
pub(crate) use self::codex::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
codex_model_capabilities_for_transport,
};
pub(crate) use self::deepseek::{apply_deepseek_tool_call_thinking_compat, is_deepseek_provider};
pub(crate) use self::family::{
@@ -25,9 +26,11 @@ pub(crate) use self::family::{
pub(crate) use self::normalize::{
build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_upstream_url,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
build_cross_format_openai_responses_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_upstream_url,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_upstream_url, validate_final_openai_provider_request,
};
pub(crate) use self::openai::{
build_local_openai_chat_stream_attempt_source_for_kind,
@@ -297,7 +300,7 @@ mod tests {
let converted = build_standard_request_body(
&request,
"claude:messages",
"gpt-5",
"gpt-5.4",
"codex",
"openai:responses",
"/v1/messages",
@@ -309,7 +312,7 @@ mod tests {
assert!(converted.get("metadata").is_none());
assert_eq!(converted["store"], false);
assert_eq!(converted["instructions"], "");
assert!(converted.get("instructions").is_none());
assert_eq!(converted["include"], json!(["reasoning.encrypted_content"]));
assert_eq!(converted["parallel_tool_calls"], true);
assert_eq!(converted["reasoning"]["effort"], "medium");
@@ -12,9 +12,38 @@ pub(crate) use self::chat::{
};
pub(crate) use self::responses::{
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_upstream_url,
};
pub(super) use crate::ai_serving::planner::common::{
enforce_provider_body_stream_policy, request_requires_body_stream_field,
};
pub(crate) fn validate_final_openai_provider_request(
provider_api_format: &str,
mapped_model: &str,
source_request_body: &serde_json::Value,
provider_request_body: &serde_json::Value,
) -> Option<()> {
let provider_model = provider_request_body
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(mapped_model);
let source_model = source_request_body
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(mapped_model);
crate::ai_serving::validate_openai_provider_request_contract(
provider_api_format,
provider_model,
source_model,
provider_request_body,
)
.ok()
}
@@ -9,7 +9,10 @@ use crate::ai_serving::{
GatewayProviderTransportSnapshot,
};
use super::{enforce_provider_body_stream_policy, request_requires_body_stream_field};
use super::{
enforce_provider_body_stream_policy, request_requires_body_stream_field,
validate_final_openai_provider_request,
};
pub(crate) fn build_local_openai_chat_request_body(
body_json: &Value,
@@ -39,6 +42,12 @@ pub(crate) fn build_local_openai_chat_request_body(
upstream_is_stream,
request_requires_body_stream_field(body_json, force_body_stream_field),
);
validate_final_openai_provider_request(
"openai:chat",
mapped_model,
body_json,
&provider_request_body,
)?;
Some(provider_request_body)
}
@@ -92,6 +101,12 @@ pub(crate) fn build_cross_format_openai_chat_request_body(
upstream_is_stream,
request_requires_body_stream_field(body_json, force_body_stream_field),
);
validate_final_openai_provider_request(
provider_api_format,
mapped_model,
body_json,
&provider_request_body,
)?;
Some(provider_request_body)
}
@@ -2,14 +2,16 @@ use serde_json::Value;
use crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers;
use crate::ai_serving::{
apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits,
build_cross_format_openai_responses_request_body_with_model_directives as surface_build_cross_format_openai_responses_request_body,
build_local_openai_responses_request_body_with_model_directives as surface_build_local_openai_responses_request_body,
GatewayProviderTransportSnapshot,
};
use super::{enforce_provider_body_stream_policy, request_requires_body_stream_field};
use super::{
enforce_provider_body_stream_policy, request_requires_body_stream_field,
validate_final_openai_provider_request,
};
pub(crate) fn build_local_openai_responses_request_body(
body_json: &Value,
@@ -19,9 +21,35 @@ pub(crate) fn build_local_openai_responses_request_body(
provider_type: &str,
provider_api_format: &str,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
_user_api_key_id: Option<&str>,
request_headers: &http::HeaderMap,
enable_model_directives: bool,
) -> Option<Value> {
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
mapped_model,
require_streaming,
force_body_stream_field,
provider_type,
provider_api_format,
body_rules,
request_headers,
None,
enable_model_directives,
)
}
pub(crate) fn build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json: &Value,
mapped_model: &str,
require_streaming: bool,
force_body_stream_field: bool,
provider_type: &str,
provider_api_format: &str,
body_rules: Option<&Value>,
request_headers: &http::HeaderMap,
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body = surface_build_local_openai_responses_request_body(
body_json,
@@ -36,12 +64,18 @@ pub(crate) fn build_local_openai_responses_request_body(
body_json,
request_headers,
)?;
apply_codex_openai_responses_special_body_edits(
let source_model = body_json
.get("model")
.and_then(Value::as_str)
.unwrap_or(mapped_model);
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
provider_type,
provider_api_format,
mapped_model,
source_model,
model_capabilities,
body_rules,
user_api_key_id,
);
apply_openai_responses_compact_special_body_edits(
&mut provider_request_body,
@@ -53,6 +87,12 @@ pub(crate) fn build_local_openai_responses_request_body(
require_streaming,
request_requires_body_stream_field(body_json, force_body_stream_field),
);
validate_final_openai_provider_request(
provider_api_format,
mapped_model,
body_json,
&provider_request_body,
)?;
Some(provider_request_body)
}
@@ -65,9 +105,37 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
force_body_stream_field: bool,
provider_type: &str,
body_rules: Option<&Value>,
user_api_key_id: Option<&str>,
_user_api_key_id: Option<&str>,
request_headers: &http::HeaderMap,
enable_model_directives: bool,
) -> Option<Value> {
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
body_json,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
force_body_stream_field,
provider_type,
body_rules,
request_headers,
None,
enable_model_directives,
)
}
pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
force_body_stream_field: bool,
provider_type: &str,
body_rules: Option<&Value>,
request_headers: &http::HeaderMap,
model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>,
enable_model_directives: bool,
) -> Option<Value> {
let provider_request_body = surface_build_cross_format_openai_responses_request_body(
body_json,
@@ -84,12 +152,18 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
body_json,
request_headers,
)?;
apply_codex_openai_responses_special_body_edits(
let source_model = body_json
.get("model")
.and_then(Value::as_str)
.unwrap_or(mapped_model);
crate::ai_serving::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
&mut provider_request_body,
provider_type,
provider_api_format,
mapped_model,
source_model,
model_capabilities,
body_rules,
user_api_key_id,
);
apply_openai_responses_compact_special_body_edits(
&mut provider_request_body,
@@ -101,6 +175,12 @@ pub(crate) fn build_cross_format_openai_responses_request_body(
upstream_is_stream,
request_requires_body_stream_field(body_json, force_body_stream_field),
);
validate_final_openai_provider_request(
provider_api_format,
mapped_model,
body_json,
&provider_request_body,
)?;
Some(provider_request_body)
}
@@ -6,8 +6,8 @@ use http::Request;
use serde_json::{json, Value};
use super::{
build_cross_format_openai_responses_request_body, build_local_openai_responses_request_body,
build_local_openai_responses_upstream_url,
build_cross_format_openai_responses_request_body, build_local_openai_chat_request_body,
build_local_openai_responses_request_body, build_local_openai_responses_upstream_url,
};
fn object_keys(value: &Value) -> Vec<&str> {
@@ -146,12 +146,10 @@ fn local_openai_responses_wrapper_preserves_body_order_after_edits() {
"reasoning",
"tool_choice",
"parallel_tool_calls",
"instructions",
"prompt_cache_key",
]
);
assert_eq!(provider_request_body["parallel_tool_calls"], json!(true));
assert_eq!(provider_request_body["instructions"], json!(""));
assert!(provider_request_body.get("instructions").is_none());
}
#[test]
@@ -181,18 +179,49 @@ fn local_openai_responses_compact_wrapper_strips_store_for_same_format_requests(
}
#[test]
fn local_openai_responses_compact_wrapper_strips_include_for_codex_requests() {
fn local_codex_compact_wrapper_applies_the_complete_request_projection() {
let body_json = json!({
"model": "gpt-5.4",
"input": [],
"model": "gpt-5.6-sol",
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}],
"instructions": "Work carefully",
"client_metadata": {"origin": "codex"},
"include": ["reasoning.encrypted_content"],
"store": true,
"stream": true
"stream": true,
"stream_options": {"reasoning_summary_delivery": "sequential_cutoff"},
"tool_choice": "auto",
"parallel_tool_calls": true,
"reasoning": {"effort": "max", "context": "all_turns"},
"text": {"verbosity": "medium"},
"tools": [{
"type": "function",
"name": "lookup",
"parameters": {"type": "object", "properties": {}}
}],
"service_tier": "priority",
"prompt_cache_key": "thread-compact"
});
let provider_request_body = build_local_openai_responses_request_body(
let regular = build_local_openai_responses_request_body(
&body_json,
"gpt-5.4",
"gpt-5.6-sol",
true,
false,
"codex",
"openai:responses",
None,
Some("key-123"),
&http::HeaderMap::new(),
false,
)
.expect("local Codex Responses body should build");
let compact = build_local_openai_responses_request_body(
&body_json,
"gpt-5.6-sol",
false,
false,
"codex",
@@ -202,22 +231,44 @@ fn local_openai_responses_compact_wrapper_strips_include_for_codex_requests() {
&http::HeaderMap::new(),
false,
)
.expect("local codex compact body should build");
.expect("local Codex Compact body should build");
assert!(provider_request_body.get("include").is_none());
assert!(provider_request_body.get("store").is_none());
assert!(provider_request_body.get("stream").is_none());
assert_eq!(provider_request_body["instructions"], "");
assert_eq!(
provider_request_body["prompt_cache_key"],
"3d2e2842-74cb-55dd-803a-b8940b3500c2"
);
for field in [
"client_metadata",
"include",
"store",
"stream",
"stream_options",
"tool_choice",
] {
assert!(
regular.get(field).is_some(),
"Responses should contain {field}"
);
assert!(compact.get(field).is_none(), "Compact should omit {field}");
}
for field in [
"model",
"input",
"instructions",
"parallel_tool_calls",
"reasoning",
"text",
"tools",
"service_tier",
"prompt_cache_key",
] {
assert_eq!(
compact[field], regular[field],
"Compact should preserve {field}"
);
}
}
#[test]
fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
let body_json = json!({
"model": "gpt-5.4-max",
"model": "gpt-5.6-sol-max",
"input": "hello",
"reasoning": {"effort": "low", "summary": "auto"}
});
@@ -227,7 +278,7 @@ fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
let provider_request_body = build_local_openai_responses_request_body(
&body_json,
"gpt-5.4",
"gpt-5.6-sol",
false,
false,
"openai",
@@ -244,6 +295,132 @@ fn local_openai_responses_wrapper_applies_model_directive_before_body_rules() {
assert_eq!(provider_request_body["metadata"]["override_seen"], true);
}
#[test]
fn final_openai_provider_contract_uses_the_mapped_model_for_reasoning() {
let alias = json!({
"model": "deployment-alias",
"input": "hello",
"reasoning": {"effort": "max"}
});
assert!(build_local_openai_responses_request_body(
&alias,
"gpt-5.6-sol",
false,
false,
"openai",
"openai:responses",
None,
None,
&http::HeaderMap::new(),
false,
)
.is_some());
assert!(build_local_openai_responses_request_body(
&alias,
"gpt-5.4",
false,
false,
"openai",
"openai:responses",
None,
None,
&http::HeaderMap::new(),
false,
)
.is_none());
let minimal = json!({
"model": "deployment-alias",
"messages": [{"role": "user", "content": "hello"}],
"reasoning_effort": "minimal"
});
assert!(build_local_openai_chat_request_body(
&minimal,
"gpt-5.6-terra",
false,
false,
None,
&http::HeaderMap::new(),
false,
)
.is_none());
let opaque_mapping = json!({
"model": "gpt-5.6-sol-max",
"input": "hello",
"reasoning": {"effort": "max", "mode": "pro"},
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
});
assert!(build_local_openai_responses_request_body(
&opaque_mapping,
"azure-production",
false,
false,
"openai",
"openai:responses",
None,
None,
&http::HeaderMap::new(),
false,
)
.is_some());
assert!(build_local_openai_responses_request_body(
&opaque_mapping,
"gpt-5.4",
false,
false,
"openai",
"openai:responses",
None,
None,
&http::HeaderMap::new(),
false,
)
.is_none());
}
#[test]
fn final_openai_provider_contract_validates_body_rule_output() {
let body = json!({
"model": "gpt-5.6-sol",
"input": "hello",
"reasoning": {"effort": "max"}
});
let model_override = json!([
{"action":"set","path":"model","value":"gpt-5.4"}
]);
assert!(build_local_openai_responses_request_body(
&body,
"gpt-5.6-sol",
false,
false,
"openai",
"openai:responses",
Some(&model_override),
None,
&http::HeaderMap::new(),
false,
)
.is_none());
let cache_override = json!([
{"action":"set","path":"prompt_cache_options.ttl","value":"1h"}
]);
assert!(build_local_openai_responses_request_body(
&json!({"model":"gpt-5.6-sol","input":"hello"}),
"gpt-5.6-sol",
false,
false,
"openai",
"openai:responses",
Some(&cache_override),
None,
&http::HeaderMap::new(),
false,
)
.is_none());
}
#[test]
fn local_openai_responses_upstream_url_preserves_codex_base_path() {
let request = Request::builder()
@@ -371,7 +548,7 @@ fn applies_codex_defaults_unless_body_rules_handle_the_field() {
}
#[test]
fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
fn omits_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
let body_json = json!({
"model": "claude-sonnet-4-5",
"messages": [{
@@ -395,14 +572,11 @@ fn injects_codex_prompt_cache_key_for_openai_responses_cross_format_requests() {
)
.expect("claude cli to codex request should build");
assert_eq!(
provider_request_body["prompt_cache_key"],
"4ee6ea6e-3ac6-5a18-8cb8-1f8b956419e5"
);
assert!(provider_request_body.get("prompt_cache_key").is_none());
}
#[test]
fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
fn omits_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
let body_json = json!({
"model": "gpt-5",
"messages": [{
@@ -425,8 +599,5 @@ fn injects_codex_prompt_cache_key_for_openai_chat_cross_format_requests() {
)
.expect("openai chat to codex request should build");
assert_eq!(
provider_request_body["prompt_cache_key"],
"4ee6ea6e-3ac6-5a18-8cb8-1f8b956419e5"
);
assert!(provider_request_body.get("prompt_cache_key").is_none());
}
@@ -6,7 +6,7 @@ use crate::ai_serving::planner::report_context::{
insert_provider_stream_event_api_format, LocalExecutionReportContextParts,
};
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -44,6 +44,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
candidate_id,
..
} = attempt;
let upstream_is_stream = upstream_is_stream && eligible.candidate.supports_streaming;
let payload_started_at = std::time::Instant::now();
let Some(resolved) = resolve_local_openai_chat_candidate_payload_parts(
state,
@@ -72,6 +73,14 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
payload_started_at.elapsed().as_millis() as u64,
);
let candidate = &eligible.candidate;
let upstream_is_stream =
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
resolved.transport.endpoint.config.as_ref(),
resolved.transport.provider.provider_type.as_str(),
resolved.provider_api_format.as_str(),
upstream_is_stream,
false,
);
let prompt_cache_key = resolved
.provider_request_body
@@ -208,7 +217,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
"stream_candidate_report_context",
report_context_started_at.elapsed().as_millis() as u64,
);
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let decision_started_at = std::time::Instant::now();
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
@@ -219,6 +228,7 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
request_id: trace_id.to_string(),
candidate_id: candidate_id.clone(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -236,8 +246,8 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts,
@@ -246,7 +256,11 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
observe_gateway_stage_ms(
"stream_candidate_decision_build",
decision_started_at.elapsed().as_millis() as u64,
@@ -25,11 +25,11 @@ use crate::ai_serving::planner::redaction::{
request_identity_response_encoding_when_redacted, resolve_provider_chat_pii_redaction,
};
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_codex_openai_responses_special_body_edits, apply_codex_openai_special_headers,
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_upstream_url, build_local_openai_chat_request_body,
build_local_openai_chat_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
build_local_openai_chat_upstream_url, codex_model_capabilities_for_transport,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::resolve_local_openai_bearer_auth;
@@ -56,7 +56,10 @@ use crate::ai_serving::transport::{
use crate::ai_serving::{
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
LocalResolvedOAuthRequestAuth,
LocalResolvedOAuthRequestAuth, OpenAiImageOperation,
};
use crate::ai_serving::{
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
};
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
use crate::stage_metrics::observe_gateway_stage_ms;
@@ -88,37 +91,7 @@ pub(crate) struct LocalOpenAiChatCandidatePayloadParts {
}
#[derive(Default)]
pub(crate) struct LocalOpenAiChatRequestPreparation {
model_directives_enabled: BTreeMap<(String, String), bool>,
}
impl LocalOpenAiChatRequestPreparation {
async fn model_directives_enabled(
&mut self,
state: &AppState,
provider_api_format: &str,
requested_model: &str,
) -> bool {
let key = (
provider_api_format.trim().to_ascii_lowercase(),
requested_model.trim().to_string(),
);
if let Some(enabled) = self.model_directives_enabled.get(&key) {
crate::stage_metrics::record_openai_chat_model_directive_cache_hit();
return *enabled;
}
crate::stage_metrics::record_openai_chat_model_directive_cache_miss();
let enabled =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(requested_model),
)
.await;
self.model_directives_enabled.insert(key, enabled);
enabled
}
}
pub(crate) struct LocalOpenAiChatRequestPreparation;
fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
matches!(
@@ -127,6 +100,65 @@ fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
)
}
fn finalize_openai_chat_provider_request_body(
provider_request_body: &mut Value,
custom_directive_mapping: Option<&Value>,
provider_api_format: &str,
upstream_is_stream: bool,
force_body_stream_field: bool,
original_body: &Value,
transport: &GatewayProviderTransportSnapshot,
mapped_model: &str,
) -> bool {
if let Some(mapping) = custom_directive_mapping {
crate::ai_serving::apply_model_directive_mapping_patch(provider_request_body, mapping);
}
// Mapping and endpoint body rules can both write `stream`. The resolved transport
// policy is authoritative and therefore runs after every body mutation.
enforce_provider_body_stream_policy(
provider_request_body,
provider_api_format,
upstream_is_stream,
request_requires_body_stream_field(original_body, force_body_stream_field),
);
apply_deepseek_tool_call_thinking_compat(
provider_request_body,
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
provider_api_format,
Some(original_body),
);
let source_model = original_body
.get("model")
.and_then(Value::as_str)
.unwrap_or(mapped_model);
let codex_model_capabilities = codex_model_capabilities_for_transport(
transport,
provider_api_format,
mapped_model,
source_model,
);
crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: "openai:chat",
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: mapped_model,
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
original_body,
force_body_stream_field,
),
},
codex_model_capabilities.as_ref(),
)
.is_ok()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
state: &AppState,
@@ -134,7 +166,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
trace_id: &str,
body_json: &serde_json::Value,
input: &LocalOpenAiChatDecisionInput,
mut preparation: Option<&mut LocalOpenAiChatRequestPreparation>,
_preparation: Option<&mut LocalOpenAiChatRequestPreparation>,
eligible: &EligibleLocalExecutionCandidate,
candidate_index: u32,
candidate_id: &str,
@@ -151,18 +183,9 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
let force_body_stream_field =
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
let model_directives_started_at = std::time::Instant::now();
let enable_model_directives = if let Some(preparation) = preparation {
preparation
.model_directives_enabled(state, provider_api_format, &input.requested_model)
.await
} else {
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await
};
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
observe_gateway_stage_ms(
"openai_chat_payload_model_directives",
model_directives_started_at.elapsed().as_millis() as u64,
@@ -220,15 +243,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
return Ok(None);
}
};
let model_directive_mapping = match model_directive_resolution
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
{
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let Some(provider_request_body) = build_local_openai_chat_request_body(
let Some(mut provider_request_body) = build_local_openai_chat_request_body(
body_json,
&prepared_candidate.mapped_model,
upstream_is_stream,
force_body_stream_field,
transport.endpoint.body_rules.as_ref(),
effective_headers,
enable_model_directives,
false,
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
@@ -247,6 +288,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await;
return Ok(None);
};
if !finalize_openai_chat_provider_request_body(
&mut provider_request_body,
model_directive_mapping.as_ref(),
provider_api_format,
upstream_is_stream,
force_body_stream_field,
body_json,
transport,
&prepared_candidate.mapped_model,
) {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
body_json,
"openai:chat",
provider_api_format,
),
)
.await;
return Ok(None);
}
let upstream_url = build_grok_upstream_url(transport, GROK_CHAT_PATH);
let Some(mut provider_request_headers) = build_grok_browser_headers(GrokHeaderInput {
@@ -379,6 +447,24 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
return Ok(None);
}
};
let model_directive_mapping = match model_directive_resolution
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
{
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
observe_gateway_stage_ms(
"openai_chat_payload_auth_prepare",
auth_prepare_started_at.elapsed().as_millis() as u64,
@@ -392,7 +478,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
force_body_stream_field,
transport.endpoint.body_rules.as_ref(),
effective_headers,
enable_model_directives,
false,
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
@@ -415,13 +501,33 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
"openai_chat_payload_body_build",
body_build_started_at.elapsed().as_millis() as u64,
);
apply_deepseek_tool_call_thinking_compat(
if !finalize_openai_chat_provider_request_body(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
model_directive_mapping.as_ref(),
"openai:chat",
Some(body_json),
);
upstream_is_stream,
force_body_stream_field,
body_json,
transport,
&prepared_candidate.mapped_model,
) {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
body_json,
"openai:chat",
provider_api_format,
),
)
.await;
return Ok(None);
}
let Some(upstream_url) = build_local_openai_chat_upstream_url(parts, transport) else {
mark_skipped_local_openai_chat_candidate_with_failure_diagnostic(
@@ -475,7 +581,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
return Ok(None);
};
let mut provider_request_headers = resolved_headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
@@ -651,6 +757,24 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
}
}
};
let model_directive_mapping = match model_directive_resolution
.mapping_patch_for_mapped_model(&prepared_candidate.mapped_model)
{
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_openai_chat_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let Some(mut provider_request_body) = build_cross_format_openai_chat_request_body(
body_json,
@@ -666,7 +790,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
},
Some(input.auth_context.api_key_id.as_str()),
effective_headers,
enable_model_directives,
false,
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
@@ -689,34 +813,37 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
.await;
return Ok(None);
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format.as_str(),
Some(&input.requested_model),
)
.await
{
crate::ai_serving::apply_model_directive_mapping_patch(
&mut provider_request_body,
&mapping,
);
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
// re-enforce stream-field policy afterward.
enforce_provider_body_stream_policy(
&mut provider_request_body,
provider_api_format.as_str(),
upstream_is_stream,
request_requires_body_stream_field(body_json, force_body_stream_field),
);
}
apply_deepseek_tool_call_thinking_compat(
if !finalize_openai_chat_provider_request_body(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
model_directive_mapping.as_ref(),
provider_api_format.as_str(),
Some(body_json),
);
upstream_is_stream,
force_body_stream_field,
body_json,
transport,
&prepared_candidate.mapped_model,
) {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_conversion_failure_extra_data(
body_json,
"openai:chat",
provider_api_format.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_chat_request_conversion",
),
)
.await;
return Ok(None);
}
if let Some(kiro_auth) = kiro_auth.as_ref() {
return Ok(build_kiro_openai_chat_cross_format_payload_parts(
@@ -845,7 +972,7 @@ pub(crate) async fn resolve_local_openai_chat_candidate_payload_parts(
return Ok(None);
};
let mut provider_request_headers = resolved_headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
@@ -997,7 +1124,7 @@ async fn build_antigravity_openai_chat_cross_format_payload_parts(
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&resolved.body,
effective_headers,
@@ -1148,7 +1275,7 @@ async fn build_gemini_cli_openai_chat_cross_format_payload_parts(
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&resolved.body,
effective_headers,
@@ -1255,6 +1382,19 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web");
let is_codex = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex");
let upstream_is_stream =
crate::ai_serving::planner::common::resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
provider_api_format,
upstream_is_stream,
false,
);
let Some((mut provider_request_body, image_request_summary)) = (if is_chatgpt_web {
build_chatgpt_web_image_provider_body_from_openai_chat_body(
body_json,
@@ -1263,7 +1403,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
} else {
build_openai_image_provider_body_from_openai_chat_body(
body_json,
&input.requested_model,
&prepared_candidate.mapped_model,
upstream_is_stream,
)
}) else {
@@ -1280,24 +1420,70 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
.await;
return Ok(None);
};
let Some(operation) = openai_image_operation_from_summary(&image_request_summary) else {
return Ok(None);
};
if !is_chatgpt_web {
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
provider_api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
let Some(projected) = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
) else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
body_json,
"openai:chat",
provider_api_format,
),
)
.await;
return Ok(None);
};
provider_request_body = projected;
}
if is_codex {
let Some(projected) =
project_codex_openai_image_api_request_body(&provider_request_body, operation)
else {
mark_skipped_local_openai_chat_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_body_build_failure_extra_data(
body_json,
"openai:chat",
provider_api_format,
),
)
.await;
return Ok(None);
};
provider_request_body = projected;
}
let upstream_url = if is_chatgpt_web {
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
} else {
build_openai_image_upstream_url(
transport,
Some("/v1/images/generations"),
parts.uri.query(),
)
let request_path = match operation {
OpenAiImageOperation::Generate => "/v1/images/generations",
OpenAiImageOperation::Edit => "/v1/images/edits",
};
build_openai_image_upstream_url(transport, Some(request_path), parts.uri.query())
};
let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
@@ -1305,7 +1491,13 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
headers: &parts.headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
accept: if is_codex {
None
} else if upstream_is_stream {
Some("text/event-stream")
} else {
Some("application/json")
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
@@ -1331,7 +1523,7 @@ async fn resolve_openai_chat_to_openai_image_payload_parts(
if is_chatgpt_web {
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else {
apply_codex_openai_responses_special_headers(
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
&parts.headers,
@@ -1386,46 +1578,24 @@ fn build_openai_image_provider_body_from_openai_chat_body(
copy_openai_chat_image_option(body_json, &mut image_options, "input_fidelity");
copy_openai_chat_image_option(body_json, &mut image_options, "partial_images");
let input = if images.is_empty() {
serde_json::json!([{
"role": "user",
"content": prompt,
}])
} else {
let mut content = vec![serde_json::json!({
"type": "input_text",
"text": prompt,
})];
content.extend(images);
serde_json::json!([{
"role": "user",
"content": content,
}])
};
let mut body = serde_json::Map::new();
if let Some(model) = body_json
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
let requested_model = requested_model.trim();
(!requested_model.is_empty()).then_some(requested_model)
})
{
body.insert("model".to_string(), Value::String(model.to_string()));
let requested_model = requested_model.trim();
if requested_model.is_empty() {
return None;
}
body.insert("input".to_string(), input);
let mut image_tool = image_options.clone();
image_tool.insert(
"type".to_string(),
Value::String("image_generation".to_string()),
);
body.insert(
"tools".to_string(),
Value::Array(vec![Value::Object(image_tool)]),
"model".to_string(),
Value::String(requested_model.to_string()),
);
body.insert("prompt".to_string(), Value::String(prompt));
body.extend(image_options.clone());
if operation == "edit" {
let image_urls = openai_image_inputs_as_api_urls(&images);
if image_urls.len() != images.len() {
return None;
}
body.insert("images".to_string(), Value::Array(image_urls));
}
if upstream_is_stream {
body.insert("stream".to_string(), Value::Bool(true));
}
@@ -1451,6 +1621,14 @@ fn build_openai_image_provider_body_from_openai_chat_body(
Some((Value::Object(body), Value::Object(summary)))
}
fn openai_image_operation_from_summary(summary: &Value) -> Option<OpenAiImageOperation> {
match summary.get("operation")?.as_str()? {
"generate" => Some(OpenAiImageOperation::Generate),
"edit" => Some(OpenAiImageOperation::Edit),
_ => None,
}
}
fn build_chatgpt_web_image_provider_body_from_openai_chat_body(
body_json: &Value,
requested_model: &str,
@@ -1603,6 +1781,20 @@ fn openai_image_inputs_as_urls(images: &[Value]) -> Vec<Value> {
.collect()
}
fn openai_image_inputs_as_api_urls(images: &[Value]) -> Vec<Value> {
images
.iter()
.filter_map(|image| {
image
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| json!({ "image_url": value }))
})
.collect()
}
fn chatgpt_web_ratio_for_size(size: &str) -> String {
let Some((width, height)) = size.split_once('x') else {
return "1:1".to_string();
@@ -2011,6 +2203,7 @@ mod tests {
routing_policy: None,
routing_trace_seed: None,
routing_context: None,
model_directive_policy: Default::default(),
}
}
@@ -2096,6 +2289,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gemini-2.5-pro".to_string(),
selected_provider_model_name: "gemini-2.5-pro".to_string(),
supports_streaming: true,
mapping_matched_model: None,
},
transport: Arc::new(sample_gemini_cli_transport()),
@@ -2138,6 +2332,238 @@ mod tests {
eligible
}
fn sample_openai_chat_eligible(provider_type: &str) -> EligibleLocalExecutionCandidate {
let mut transport = sample_gemini_cli_transport();
transport.provider.name = provider_type.to_string();
transport.provider.provider_type = provider_type.to_string();
transport.endpoint.api_format = "openai:chat".to_string();
transport.endpoint.api_family = Some("openai".to_string());
transport.endpoint.endpoint_kind = Some("chat_completions".to_string());
transport.endpoint.base_url = if provider_type == "grok" {
"https://grok.com".to_string()
} else {
"https://api.openai.test".to_string()
};
transport.endpoint.custom_path = None;
transport.key.api_formats = Some(vec!["openai:chat".to_string()]);
transport.key.upstream_metadata = None;
if provider_type == "grok" {
transport.key.auth_type = "oauth".to_string();
transport.key.decrypted_api_key.clear();
transport.key.decrypted_auth_config =
Some(json!({ "sso_token": "test-session" }).to_string());
} else {
transport.key.auth_type = "bearer".to_string();
transport.key.decrypted_api_key = "test-api-key".to_string();
transport.key.decrypted_auth_config = None;
}
let mut eligible = sample_gemini_cli_eligible();
eligible.candidate.provider_name = provider_type.to_string();
eligible.candidate.provider_type = provider_type.to_string();
eligible.candidate.endpoint_api_format = "openai:chat".to_string();
eligible.candidate.global_model_name = "gpt-5.6-sol".to_string();
eligible.candidate.selected_provider_model_name = "gpt-5.6-sol".to_string();
eligible.transport = Arc::new(transport);
eligible.provider_api_format = "openai:chat".to_string();
eligible
}
fn sample_custom_directive_input() -> LocalOpenAiChatDecisionInput {
let mut input = sample_input();
input.requested_model = "gpt-5.6-sol-high".to_string();
input.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
Some(&json!({
"reasoning_effort": {
"api_formats": {
"openai:chat": {
"suffixes": ["high"],
"mappings": {
"high": {
"reasoning_effort": "low",
"stream": false
}
}
}
}
}
})),
);
input
}
fn sample_alias_max_directive_input() -> LocalOpenAiChatDecisionInput {
let mut input = sample_input();
input.requested_model = "deployment-alias-max".to_string();
input.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
None,
);
input
}
#[tokio::test]
async fn alias_reasoning_directive_is_constrained_by_the_mapped_openai_model() {
let state = AppState::new().expect("state should build");
let request = http::Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(http::header::CONTENT_TYPE, "application/json")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body_json = json!({
"model": "deployment-alias-max",
"messages": [{"role": "user", "content": "hello"}]
});
let mut supported = sample_openai_chat_eligible("custom");
supported.candidate.selected_provider_model_name = "gpt-5.6-sol".to_string();
let payload = resolve_local_openai_chat_candidate_payload_parts(
&state,
&parts,
"trace-alias-max-gpt-5.6-sol",
&body_json,
&sample_alias_max_directive_input(),
None,
&supported,
0,
"candidate-0",
"openai_chat_sync",
"openai_chat_sync_success",
false,
)
.await
.expect("candidate resolution should not fail")
.expect("GPT-5.6 candidate should build a payload");
assert_eq!(payload.provider_request_body["reasoning_effort"], "max");
let mut unsupported = sample_openai_chat_eligible("custom");
unsupported.candidate.selected_provider_model_name = "gpt-5.4".to_string();
let payload = resolve_local_openai_chat_candidate_payload_parts(
&state,
&parts,
"trace-alias-max-gpt-5.4",
&body_json,
&sample_alias_max_directive_input(),
None,
&unsupported,
0,
"candidate-0",
"openai_chat_sync",
"openai_chat_sync_success",
false,
)
.await
.expect("candidate resolution should not fail");
assert!(payload.is_none(), "GPT-5.4 must reject the max directive");
}
#[tokio::test]
async fn custom_policy_suffix_patch_is_applied_after_candidate_mapping() {
let state = AppState::new().expect("state should build");
let request = http::Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(http::header::CONTENT_TYPE, "application/json")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body_json = json!({
"model": "deployment-alias-VendorFuture",
"messages": [{"role": "user", "content": "hello"}]
});
let mut input = sample_input();
input.requested_model = "deployment-alias-VendorFuture".to_string();
input.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
Some(&json!({
"reasoning_effort": {
"api_formats": {
"openai:chat": {
"suffixes": ["VendorFuture"],
"mappings": {
"VendorFuture": {
"reasoning_effort": "high"
}
}
}
}
}
})),
);
let payload = resolve_local_openai_chat_candidate_payload_parts(
&state,
&parts,
"trace-custom-policy-suffix",
&body_json,
&input,
None,
&sample_openai_chat_eligible("custom"),
0,
"candidate-0",
"openai_chat_sync",
"openai_chat_sync_success",
false,
)
.await
.expect("candidate resolution should not fail")
.expect("custom directive candidate should build a payload");
assert_eq!(payload.provider_request_body["model"], "gpt-5.6-sol");
assert_eq!(payload.provider_request_body["reasoning_effort"], "high");
}
#[tokio::test]
async fn same_format_and_grok_chat_apply_the_same_custom_directive_finalization() {
let state = AppState::new().expect("state should build");
let request = http::Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header(http::header::CONTENT_TYPE, "application/json")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body_json = json!({
"model": "gpt-5.6-sol-high",
"messages": [{"role": "user", "content": "hello"}],
"stream": true
});
for provider_type in ["custom", "grok"] {
let payload = resolve_local_openai_chat_candidate_payload_parts(
&state,
&parts,
&format!("trace-directive-{provider_type}"),
&body_json,
&sample_custom_directive_input(),
None,
&sample_openai_chat_eligible(provider_type),
0,
"candidate-0",
OPENAI_CHAT_STREAM_PLAN_KIND,
"openai_chat_stream_success",
true,
)
.await
.expect("candidate resolution should not fail")
.expect("same-format candidate should build a payload");
assert_eq!(
payload.provider_request_body["reasoning_effort"], "low",
"custom mapping must be authoritative for {provider_type}"
);
assert_eq!(
payload.provider_request_body["stream"], true,
"stream policy must be re-applied after mapping for {provider_type}"
);
}
}
#[tokio::test]
async fn openai_chat_to_gemini_cli_wraps_cross_format_body_in_v1internal_envelope() {
let state = AppState::new().expect("state should build");
@@ -2333,7 +2759,7 @@ mod tests {
}
#[test]
fn openai_chat_image_bridge_body_injects_image_generation_tool() {
fn openai_chat_image_bridge_builds_images_api_body() {
let body_json = json!({
"model": "gpt-image-2",
"messages": [
@@ -2347,13 +2773,22 @@ mod tests {
build_openai_image_provider_body_from_openai_chat_body(&body_json, "gpt-image-2", true)
.expect("chat image body should convert");
assert_eq!(provider_body["tools"][0]["type"], "image_generation");
assert_eq!(provider_body["tools"][0]["size"], "1024x1024");
assert_eq!(provider_body["tools"][0]["output_format"], "png");
assert_eq!(provider_body["model"], "gpt-image-2");
assert_eq!(provider_body["prompt"], "Draw a glass city");
assert_eq!(provider_body["size"], "1024x1024");
assert_eq!(provider_body["output_format"], "png");
assert_eq!(provider_body["stream"], true);
assert_eq!(provider_body["input"][0]["content"], "Draw a glass city");
assert!(provider_body.get("tools").is_none());
assert!(provider_body.get("input").is_none());
assert_eq!(summary["operation"], "generate");
assert_eq!(summary["output_format"], "png");
let (sync_provider_body, _) = build_openai_image_provider_body_from_openai_chat_body(
&body_json,
"gpt-image-2",
false,
)
.expect("chat image body should convert for a sync upstream");
assert!(sync_provider_body.get("stream").is_none());
}
}
@@ -293,6 +293,7 @@ pub(crate) async fn build_lazy_local_openai_chat_candidate_attempt_source<'a>(
);
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
planner_state,
&input.model_directive_policy,
trace_id,
"openai:chat",
&input.requested_model,
@@ -21,6 +21,7 @@ pub(crate) async fn list_local_openai_chat_candidates(
> {
let outcome = preselect_local_execution_candidates_with_serving(
PlannerAppState::new(state),
&input.model_directive_policy,
"openai:chat",
&input.requested_model,
require_streaming,
@@ -65,7 +65,9 @@ pub(crate) async fn resolve_local_openai_chat_decision_input(
state,
auth_context.clone(),
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -111,7 +111,7 @@ pub(crate) async fn build_local_openai_chat_stream_attempt_source<'a>(
input,
candidates,
prefetched_attempts: VecDeque::new(),
request_preparation: LocalOpenAiChatRequestPreparation::default(),
request_preparation: LocalOpenAiChatRequestPreparation,
},
candidate_count,
)))
@@ -129,7 +129,7 @@ pub(crate) fn build_openai_chat_stream_plan_from_decision(
headers: std::mem::take(&mut provider_request_headers),
content_type,
body: RequestBody::from_json(provider_request_body_value),
stream: true,
stream: effective_upstream_is_stream,
},
);
@@ -229,7 +229,7 @@ pub(crate) fn build_openai_responses_stream_plan_from_decision(
headers: std::mem::take(&mut provider_request_headers),
content_type,
body: RequestBody::from_json(provider_request_body_value),
stream: true,
stream: effective_upstream_is_stream,
},
);
@@ -291,6 +291,7 @@ mod tests {
request_id: Some("req_123".to_string()),
candidate_id: Some("cand_123".to_string()),
provider_name: Some("Codex".to_string()),
provider_type: Some("codex".to_string()),
provider_id: Some("prov_123".to_string()),
endpoint_id: Some("ep_123".to_string()),
key_id: Some("key_123".to_string()),
@@ -385,6 +386,46 @@ mod tests {
);
}
#[test]
fn build_compact_stream_plan_preserves_non_stream_upstream_mode() {
let parts = http::Request::builder()
.uri("http://localhost/v1/responses/compact")
.body(())
.expect("request should build")
.into_parts()
.0;
let mut payload = sample_responses_payload();
payload.decision_kind = Some("openai_responses_compact_stream".to_string());
payload.upstream_url = Some("https://example.com/v1/responses/compact".to_string());
payload.provider_api_format = Some("openai:responses:compact".to_string());
payload.client_api_format = Some("openai:responses:compact".to_string());
payload.upstream_is_stream = false;
payload.provider_request_body = Some(json!({
"model": "gpt-5.6-sol",
"input": [],
"instructions": "You are Codex.",
"tools": [],
"parallel_tool_calls": true,
"reasoning": {"effort": "high"},
"prompt_cache_key": "cache-key",
"text": {"verbosity": "low"}
}));
let built =
build_openai_responses_stream_plan_from_decision(&parts, &json!({}), payload, true)
.expect("plan build should succeed")
.expect("plan should be produced");
assert!(!built.plan.stream);
assert!(built
.plan
.body
.json_body
.as_ref()
.is_some_and(|body| body.get("stream").is_none()));
assert!(built.plan.headers.get("accept").is_none());
}
#[test]
fn build_openai_chat_stream_plan_fallback_preserves_complete_same_format_headers() {
let parts = http::Request::builder()
@@ -404,6 +445,7 @@ mod tests {
request_id: Some("req_stream_456".to_string()),
candidate_id: Some("cand_stream_456".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_type: Some("openai".to_string()),
provider_id: Some("prov_stream_456".to_string()),
endpoint_id: Some("ep_stream_456".to_string()),
key_id: Some("key_stream_456".to_string()),
@@ -462,7 +504,7 @@ mod tests {
}
#[test]
fn build_openai_chat_stream_plan_keeps_downstream_stream_for_force_non_stream_upstream() {
fn build_openai_chat_stream_plan_preserves_force_non_stream_upstream_mode() {
fn force_non_stream_payload(provider_request_body: Option<Value>) -> AiExecutionDecision {
AiExecutionDecision {
action: "stream".to_string(),
@@ -472,6 +514,7 @@ mod tests {
request_id: Some("req_force_non_stream".to_string()),
candidate_id: Some("cand_force_non_stream".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_type: Some("openai".to_string()),
provider_id: Some("prov_force_non_stream".to_string()),
endpoint_id: Some("ep_force_non_stream".to_string()),
key_id: Some("key_force_non_stream".to_string()),
@@ -523,7 +566,7 @@ mod tests {
.expect("plan build should succeed")
.expect("plan should be produced");
assert!(built.plan.stream);
assert!(!built.plan.stream);
assert_eq!(
built
.plan
@@ -548,7 +591,7 @@ mod tests {
.expect("fallback plan build should succeed")
.expect("fallback plan should be produced");
assert!(built.plan.stream);
assert!(!built.plan.stream);
assert_eq!(
built
.plan
@@ -579,6 +622,7 @@ mod tests {
request_id: Some("req_stream_789".to_string()),
candidate_id: Some("cand_stream_789".to_string()),
provider_name: Some("Claude".to_string()),
provider_type: Some("anthropic".to_string()),
provider_id: Some("prov_stream_789".to_string()),
endpoint_id: Some("ep_stream_789".to_string()),
key_id: Some("key_stream_789".to_string()),
@@ -257,6 +257,7 @@ mod tests {
request_id: Some("req_123".to_string()),
candidate_id: Some("cand_123".to_string()),
provider_name: Some("Codex".to_string()),
provider_type: Some("codex".to_string()),
provider_id: Some("prov_123".to_string()),
endpoint_id: Some("ep_123".to_string()),
key_id: Some("key_123".to_string()),
@@ -369,6 +370,7 @@ mod tests {
request_id: Some("req_456".to_string()),
candidate_id: Some("cand_456".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_type: Some("openai".to_string()),
provider_id: Some("prov_456".to_string()),
endpoint_id: Some("ep_456".to_string()),
key_id: Some("key_456".to_string()),
@@ -440,6 +442,7 @@ mod tests {
request_id: Some("req_789".to_string()),
candidate_id: Some("cand_789".to_string()),
provider_name: Some("Claude".to_string()),
provider_type: Some("anthropic".to_string()),
provider_id: Some("prov_789".to_string()),
endpoint_id: Some("ep_789".to_string()),
key_id: Some("key_789".to_string()),
@@ -9,7 +9,7 @@ use crate::ai_serving::planner::report_context::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::{
build_ai_execution_decision_response, resolve_transport_request_gzip_policy,
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
AiExecutionDecisionResponseParts,
};
use crate::ai_serving::transport::{
@@ -204,7 +204,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
image_request_summary: _,
request_redacted: _,
} = resolved;
let request_gzip = resolve_transport_request_gzip_policy(&transport);
let request_encoding = resolve_transport_request_encoding_policy(&transport);
let mut decision = build_ai_execution_decision_response(AiExecutionDecisionResponseParts {
decision_is_stream: spec_metadata.require_streaming,
@@ -214,6 +214,7 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
request_id: trace_id.to_string(),
candidate_id: candidate_id.clone(),
provider_name: transport.provider.name.clone(),
provider_type: transport.provider.provider_type.clone(),
provider_id: candidate.provider_id.clone(),
endpoint_id: candidate.endpoint_id.clone(),
key_id: candidate.key_id.clone(),
@@ -231,8 +232,8 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
provider_request_body: Some(provider_request_body),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip,
content_encoding: request_encoding.content_encoding,
request_gzip: request_encoding.request_gzip,
proxy,
transport_profile,
timeouts,
@@ -241,6 +242,10 @@ pub(crate) async fn maybe_build_local_openai_responses_decision_payload_for_cand
report_context: Some(report_context),
auth_context: input.auth_context.clone(),
});
apply_provider_request_routing_policy_to_decision(input, &mut decision)?;
apply_provider_request_routing_policy_to_decision(
input,
&mut decision,
Some(transport.as_ref()),
)?;
Ok(Some(decision))
}
@@ -27,11 +27,12 @@ use crate::ai_serving::planner::redaction::{
};
use crate::ai_serving::planner::spec_metadata::local_openai_responses_spec_metadata;
use crate::ai_serving::planner::standard::{
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
apply_deepseek_tool_call_thinking_compat, build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_upstream_url, build_local_openai_responses_request_body,
build_local_openai_responses_upstream_url, request_body_build_failure_extra_data,
request_conversion_failure_extra_data,
apply_codex_openai_special_headers, apply_deepseek_tool_call_thinking_compat,
build_cross_format_openai_responses_request_body_with_codex_model_capabilities,
build_cross_format_openai_responses_upstream_url,
build_local_openai_responses_request_body_with_codex_model_capabilities,
build_local_openai_responses_upstream_url, codex_model_capabilities_for_transport,
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
};
use crate::ai_serving::transport::antigravity::is_antigravity_provider_transport;
use crate::ai_serving::transport::auth::{
@@ -58,7 +59,10 @@ use crate::ai_serving::transport::{
use crate::ai_serving::{
ai_local_execution_contract_for_formats, request_conversion_direct_auth,
request_conversion_kind, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
LocalResolvedOAuthRequestAuth, PlannerAppState,
LocalResolvedOAuthRequestAuth, OpenAiImageOperation, PlannerAppState,
};
use crate::ai_serving::{
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
};
use crate::ai_serving::{ConversionMode, ExecutionStrategy};
use crate::{AppState, GatewayError};
@@ -282,13 +286,26 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
let auth_header = prepared_candidate.auth_header;
let auth_value = prepared_candidate.auth_value;
let mapped_model = prepared_candidate.mapped_model;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await;
let model_directive_resolution = input
.model_directive_policy
.resolve_reasoning(provider_api_format, Some(&input.requested_model));
let model_directive_mapping =
match model_directive_resolution.mapping_patch_for_mapped_model(&mapped_model) {
Ok(mapping) => mapping,
Err(skip_reason) => {
mark_skipped_local_openai_responses_candidate(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
skip_reason,
)
.await;
return Ok(None);
}
};
let redaction = resolve_provider_chat_pii_redaction(
state,
parts,
@@ -311,9 +328,19 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
let force_body_stream_field =
endpoint_config_forces_body_stream_field(transport.endpoint.config.as_ref());
let effective_headers = input.effective_headers(&parts.headers);
let source_model = body_json
.get("model")
.and_then(Value::as_str)
.unwrap_or(input.requested_model.as_str());
let codex_model_capabilities = codex_model_capabilities_for_transport(
&transport,
provider_api_format,
mapped_model.as_str(),
source_model,
);
let Some(mut base_provider_request_body) =
(if is_grok && is_grok_text_provider_api_format(provider_api_format) {
build_local_openai_responses_request_body(
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
upstream_is_stream,
@@ -321,12 +348,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
transport.provider.provider_type.as_str(),
spec_metadata.api_format,
transport.endpoint.body_rules.as_ref(),
Some(input.auth_context.api_key_id.as_str()),
effective_headers,
enable_model_directives,
codex_model_capabilities.as_ref(),
false,
)
} else if needs_bidirectional_conversion {
build_cross_format_openai_responses_request_body(
build_cross_format_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
spec_metadata.api_format,
@@ -339,12 +366,12 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
effective_headers,
enable_model_directives,
codex_model_capabilities.as_ref(),
false,
)
} else {
build_local_openai_responses_request_body(
build_local_openai_responses_request_body_with_codex_model_capabilities(
body_json,
&mapped_model,
upstream_is_stream,
@@ -356,9 +383,9 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
} else {
transport.endpoint.body_rules.as_ref()
},
Some(input.auth_context.api_key_id.as_str()),
effective_headers,
enable_model_directives,
codex_model_capabilities.as_ref(),
false,
)
})
else {
@@ -383,17 +410,10 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
.await;
return Ok(None);
};
if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state,
provider_api_format,
Some(&input.requested_model),
)
.await
{
if let Some(mapping) = model_directive_mapping.as_ref() {
crate::ai_serving::apply_model_directive_mapping_patch(
&mut base_provider_request_body,
&mapping,
mapping,
);
// Directive mapping is a deep-merge patch and may overwrite/add `stream`;
// re-enforce stream-field policy afterward.
@@ -411,6 +431,46 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
provider_api_format,
Some(body_json),
);
if crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut base_provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: spec_metadata.api_format,
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: mapped_model.as_str(),
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field: request_requires_body_stream_field(
body_json,
force_body_stream_field,
),
},
codex_model_capabilities.as_ref(),
)
.is_err()
{
mark_skipped_local_openai_responses_candidate_with_extra_data(
state,
input,
trace_id,
candidate,
candidate_index,
candidate_id,
"provider_request_body_build_failed",
request_conversion_failure_extra_data(
body_json,
spec_metadata.api_format,
provider_api_format,
Some(mapped_model.as_str()),
Some(parts.uri.path()),
upstream_is_stream,
"openai_responses_request_conversion",
),
)
.await;
return Ok(None);
}
let provider_request_body = base_provider_request_body;
if let Some(kiro_auth) = kiro_auth.as_ref() {
@@ -612,7 +672,11 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
};
let mut provider_request_headers = resolved_headers.headers;
if !is_grok {
apply_codex_openai_responses_special_headers(
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
transport.key.decrypted_auth_config.as_deref(),
);
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
effective_headers,
@@ -621,9 +685,13 @@ pub(crate) async fn resolve_local_openai_responses_candidate_payload_parts(
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
apply_local_auth_config_header_overrides(
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
&mut provider_request_headers,
transport.key.decrypted_auth_config.as_deref(),
transport.provider.provider_type.as_str(),
provider_api_format,
mapped_model.as_str(),
source_model,
codex_model_capabilities.as_ref(),
);
}
request_identity_response_encoding_when_redacted(
@@ -788,7 +856,11 @@ async fn build_antigravity_openai_responses_payload_parts(
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_special_headers(
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
resolved.transport.key.decrypted_auth_config.as_deref(),
);
apply_codex_openai_special_headers(
&mut provider_request_headers,
&resolved.body,
effective_headers,
@@ -797,10 +869,6 @@ async fn build_antigravity_openai_responses_payload_parts(
Some(trace_id),
resolved.transport.key.decrypted_auth_config.as_deref(),
);
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
resolved.transport.key.decrypted_auth_config.as_deref(),
);
provider_request_headers.insert("accept".to_string(), "text/event-stream".to_string());
request_identity_response_encoding_when_redacted(
&mut provider_request_headers,
@@ -941,7 +1009,11 @@ async fn build_gemini_cli_openai_responses_payload_parts(
}
};
let mut provider_request_headers = resolved.headers.headers;
apply_codex_openai_responses_special_headers(
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
resolved.transport.key.decrypted_auth_config.as_deref(),
);
apply_codex_openai_special_headers(
&mut provider_request_headers,
&resolved.body,
effective_headers,
@@ -950,10 +1022,6 @@ async fn build_gemini_cli_openai_responses_payload_parts(
Some(trace_id),
resolved.transport.key.decrypted_auth_config.as_deref(),
);
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
resolved.transport.key.decrypted_auth_config.as_deref(),
);
request_identity_response_encoding_when_redacted(
&mut provider_request_headers,
request_redacted,
@@ -1182,11 +1250,16 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web");
let is_codex = transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("codex");
let upstream_is_stream = resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
provider_api_format,
spec_metadata.require_streaming,
spec_metadata.require_streaming && candidate.supports_streaming,
false,
);
let Some((mut provider_request_body, image_request_summary)) = (if is_chatgpt_web {
@@ -1197,7 +1270,7 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
} else {
build_openai_image_provider_body_from_openai_responses_body(
body_json,
&input.requested_model,
&prepared_candidate.mapped_model,
upstream_is_stream,
)
}) else {
@@ -1218,25 +1291,31 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
.await;
return None;
};
let operation = openai_image_operation_from_summary(&image_request_summary)?;
if !is_chatgpt_web {
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
provider_api_format,
transport.endpoint.body_rules.as_ref(),
Some(candidate.key_id.as_str()),
);
provider_request_body = project_openai_image_api_request_body(
&provider_request_body,
&prepared_candidate.mapped_model,
operation,
crate::image_capabilities::openai_image_provider_max_generation_count_for_model(
transport.provider.provider_type.as_str(),
Some(prepared_candidate.mapped_model.as_str()),
),
)?;
}
if is_codex {
provider_request_body =
project_codex_openai_image_api_request_body(&provider_request_body, operation)?;
}
let upstream_url = if is_chatgpt_web {
chatgpt_web_image_internal_url(&transport.endpoint.base_url)
} else {
build_openai_image_upstream_url(
transport,
Some("/v1/images/generations"),
parts.uri.query(),
)
let request_path = match operation {
OpenAiImageOperation::Generate => "/v1/images/generations",
OpenAiImageOperation::Edit => "/v1/images/edits",
};
build_openai_image_upstream_url(transport, Some(request_path), parts.uri.query())
};
let Some(mut provider_request_headers) =
build_openai_image_headers(ProviderOpenAiImageHeadersInput {
@@ -1244,7 +1323,13 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
headers: &parts.headers,
auth_header: &prepared_candidate.auth_header,
auth_value: &prepared_candidate.auth_value,
accept: "text/event-stream",
accept: if is_codex {
None
} else if upstream_is_stream {
Some("text/event-stream")
} else {
Some("application/json")
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
original_request_body: body_json,
@@ -1270,7 +1355,11 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
if is_chatgpt_web {
provider_request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else {
apply_codex_openai_responses_special_headers(
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
transport.key.decrypted_auth_config.as_deref(),
);
apply_codex_openai_special_headers(
&mut provider_request_headers,
&provider_request_body,
&parts.headers,
@@ -1279,10 +1368,6 @@ async fn resolve_openai_responses_to_openai_image_payload_parts(
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
apply_local_auth_config_header_overrides(
&mut provider_request_headers,
transport.key.decrypted_auth_config.as_deref(),
);
}
let (execution_strategy, conversion_mode) =
@@ -1314,59 +1399,68 @@ fn build_openai_image_provider_body_from_openai_responses_body(
upstream_is_stream: bool,
) -> Option<(Value, Value)> {
let object = body_json.as_object()?;
let input = object.get("input")?.clone();
let tool = openai_responses_image_generation_tool(object);
let (prompt, images) = collect_openai_responses_image_prompt_and_images(object.get("input"))?;
let operation = if images.is_empty() {
OpenAiImageOperation::Generate
} else {
OpenAiImageOperation::Edit
};
if let Some(action) = tool
.as_ref()
.and_then(|tool| tool.get("action"))
.and_then(Value::as_str)
{
let expected = operation.as_str();
if !action.trim().eq_ignore_ascii_case(expected) {
return None;
}
}
let mut body = serde_json::Map::new();
body.insert("input".to_string(), input);
if let Some(model) = object
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
let requested_model = requested_model.trim();
(!requested_model.is_empty()).then_some(requested_model)
})
{
body.insert("model".to_string(), Value::String(model.to_string()));
let requested_model = requested_model.trim();
if requested_model.is_empty() {
return None;
}
body.insert(
"model".to_string(),
Value::String(requested_model.to_string()),
);
body.insert("prompt".to_string(), Value::String(prompt));
for key in [
"background",
"quality",
"size",
"output_format",
"output_compression",
"moderation",
"input_fidelity",
"partial_images",
"n",
"user",
"metadata",
"include",
"parallel_tool_calls",
"store",
] {
if let Some(value) = object.get(key) {
if let Some(value) = tool
.as_ref()
.and_then(|tool| tool.get(key))
.or_else(|| object.get(key))
{
body.insert(key.to_string(), value.clone());
}
}
if operation == OpenAiImageOperation::Edit {
let image_urls = openai_image_inputs_as_api_urls(&images);
if image_urls.len() != images.len() {
return None;
}
body.insert("images".to_string(), Value::Array(image_urls));
}
if upstream_is_stream {
body.insert("stream".to_string(), Value::Bool(true));
} else if let Some(value) = object.get("stream") {
body.insert("stream".to_string(), value.clone());
}
let image_tool = tool.clone().unwrap_or_else(|| {
let mut tool = serde_json::Map::new();
tool.insert(
"type".to_string(),
Value::String("image_generation".to_string()),
);
tool
});
body.insert(
"tools".to_string(),
Value::Array(vec![Value::Object(image_tool)]),
);
let mut summary = serde_json::Map::new();
summary.insert(
"operation".to_string(),
tool.as_ref()
.and_then(|tool| tool.get("action"))
.cloned()
.unwrap_or_else(|| json!("generate")),
Value::String(operation.as_str().to_string()),
);
for key in ["output_format", "partial_images", "size", "quality"] {
let tool_value = tool.as_ref().and_then(|tool| tool.get(key));
@@ -1378,6 +1472,14 @@ fn build_openai_image_provider_body_from_openai_responses_body(
Some((Value::Object(body), Value::Object(summary)))
}
fn openai_image_operation_from_summary(summary: &Value) -> Option<OpenAiImageOperation> {
match summary.get("operation")?.as_str()? {
"generate" => Some(OpenAiImageOperation::Generate),
"edit" => Some(OpenAiImageOperation::Edit),
_ => None,
}
}
fn openai_responses_image_generation_tool(
object: &serde_json::Map<String, Value>,
) -> Option<serde_json::Map<String, Value>> {
@@ -1572,6 +1674,20 @@ fn openai_image_inputs_as_urls(images: &[Value]) -> Vec<Value> {
.collect()
}
fn openai_image_inputs_as_api_urls(images: &[Value]) -> Vec<Value> {
images
.iter()
.filter_map(|image| {
image
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| json!({ "image_url": value }))
})
.collect()
}
fn chatgpt_web_ratio_for_size(size: &str) -> String {
let Some((width, height)) = size.split_once('x') else {
return "1:1".to_string();
@@ -1769,7 +1885,7 @@ mod tests {
use super::*;
#[test]
fn openai_responses_image_bridge_body_preserves_image_generation_tool() {
fn openai_responses_image_bridge_builds_images_api_body() {
let body_json = json!({
"model": "gpt-image-2",
"input": "Draw a glass city",
@@ -1792,14 +1908,74 @@ mod tests {
)
.expect("responses image body should convert");
assert_eq!(provider_body["tools"][0]["type"], "image_generation");
assert_eq!(provider_body["tools"][0]["size"], "1024x1024");
assert_eq!(provider_body["tools"][0]["output_format"], "png");
assert_eq!(provider_body["model"], "gpt-image-2");
assert_eq!(provider_body["input"], "Draw a glass city");
assert_eq!(provider_body["prompt"], "Draw a glass city");
assert_eq!(provider_body["size"], "1024x1024");
assert_eq!(provider_body["output_format"], "png");
assert_eq!(provider_body["stream"], true);
assert!(provider_body.get("tools").is_none());
assert!(provider_body.get("input").is_none());
assert_eq!(summary["operation"], "generate");
assert_eq!(summary["output_format"], "png");
let (sync_provider_body, _) = build_openai_image_provider_body_from_openai_responses_body(
&body_json,
"gpt-image-2",
false,
)
.expect("responses image body should convert for a sync upstream");
assert!(sync_provider_body.get("stream").is_none());
}
#[test]
fn responses_image_bridge_uses_the_shared_mapped_model_projection() {
let body_json = json!({
"model": "image-alias",
"input": "Draw a glass city",
"tools": [{
"type": "image_generation",
"quality": "high",
"n": 2
}],
"tool_choice": {"type": "image_generation"}
});
let (body, _) = build_openai_image_provider_body_from_openai_responses_body(
&body_json, "dall-e-3", false,
)
.expect("Responses image body should convert before provider projection");
assert!(project_openai_image_api_request_body(
&body,
"dall-e-3",
OpenAiImageOperation::Generate,
1,
)
.is_none());
let single = json!({
"model": "dall-e-3",
"prompt": "Draw a glass city",
"quality": "high",
"n": 1
});
let projected = project_openai_image_api_request_body(
&single,
"dall-e-3",
OpenAiImageOperation::Generate,
1,
)
.expect("DALL-E 3 single image request should project");
assert_eq!(projected["quality"], "hd");
let codex_overflow = json!({
"model": "gpt-image-2",
"prompt": "Draw a glass city",
"n": 11
});
assert!(project_codex_openai_image_api_request_body(
&codex_overflow,
OpenAiImageOperation::Generate
)
.is_none());
}
#[test]
@@ -91,7 +91,9 @@ pub(crate) async fn resolve_local_openai_responses_decision_input(
state,
auth_context.clone(),
Some(requested_model.as_str()),
decision.auth_endpoint_signature.as_deref(),
None,
&decision.model_directive_policy,
)
.await
{
@@ -171,6 +173,7 @@ pub(crate) async fn materialize_local_openai_responses_candidate_attempts(
);
let preselection = preselect_local_execution_candidates_with_serving(
planner_state,
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
spec_metadata.require_streaming,
@@ -280,6 +283,7 @@ pub(crate) async fn build_local_openai_responses_candidate_attempt_source<'a>(
Ok(
build_lazy_requested_model_execution_candidate_attempt_source_with_serving(
planner_state,
&input.model_directive_policy,
trace_id,
spec_metadata.api_format,
&input.requested_model,
@@ -365,6 +369,7 @@ pub(crate) async fn build_local_openai_responses_image_candidate_attempt_source<
);
let preselection = preselect_local_execution_candidates_for_api_formats_with_serving(
planner_state,
&input.model_directive_policy,
spec_metadata.api_format,
&input.requested_model,
false,
@@ -146,6 +146,7 @@ pub(crate) fn build_standard_stream_plan_from_decision(
&provider_request_headers,
&provider_request_body_value,
)?;
let stream = payload.upstream_is_stream;
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
@@ -155,7 +156,7 @@ pub(crate) fn build_standard_stream_plan_from_decision(
headers: std::mem::take(&mut provider_request_headers),
content_type,
body: RequestBody::from_json(provider_request_body_value),
stream: true,
stream,
},
);
@@ -10,16 +10,15 @@ impl<'a> PlannerAppState<'a> {
api_key_id: &str,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Value>,
model_directive_base_model: Option<&str>,
) -> Option<Value> {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled(self.app()).await;
crate::request_candidate_runtime::resolve_request_candidate_required_capabilities(
self.app(),
user_id,
api_key_id,
requested_model,
explicit_required_capabilities,
enable_model_directives,
model_directive_base_model,
)
.await
}
@@ -20,14 +20,8 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<Vec<SchedulerMinimalCandidateSelectionCandidate>, GatewayError> {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
self.app(),
api_format,
Some(global_model_name),
)
.await;
crate::scheduler::candidate::list_selectable_candidates(
self.app().data.as_ref(),
self.app(),
@@ -52,6 +46,7 @@ impl<'a> PlannerAppState<'a> {
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
client_session_affinity: Option<&ClientSessionAffinity>,
now_unix_secs: u64,
enable_model_directives: bool,
) -> Result<
(
Vec<SchedulerMinimalCandidateSelectionCandidate>,
@@ -63,14 +58,6 @@ impl<'a> PlannerAppState<'a> {
let wait_interval = Duration::from_millis(API_KEY_CONCURRENCY_WAIT_POLL_INTERVAL_MS.max(1));
let wait_deadline = Instant::now() + wait_timeout;
let mut attempt_now_unix_secs = now_unix_secs;
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
self.app(),
api_format,
Some(global_model_name),
)
.await;
loop {
let result = crate::scheduler::candidate::list_selectable_candidates_with_skip_reasons(
self.app().data.as_ref(),
+60 -42
View File
@@ -3,10 +3,14 @@ pub(crate) use aether_ai_formats::api::{
aggregate_openai_chat_stream_sync_response, aggregate_openai_responses_stream_sync_response,
aggregate_standard_chat_stream_sync_response, aggregate_standard_cli_stream_sync_response,
api_format_alias_matches, api_format_storage_aliases,
apply_codex_openai_responses_chat_body_edits, apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_headers, apply_model_directive_mapping_patch,
apply_codex_openai_compact_terminal_headers, apply_codex_openai_responses_chat_body_edits,
apply_codex_openai_responses_lite_header_with_capabilities,
apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers, apply_model_directive_mapping_patch,
apply_model_directive_overrides_from_model, apply_model_directive_overrides_from_request,
apply_openai_responses_compact_special_body_edits, build_chatgpt_web_image_request_body,
build_codex_model_catalog_metadata, build_codex_openai_image_api_provider_request_body,
build_core_error_body_for_client_format, build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_responses_request_body,
@@ -41,17 +45,21 @@ pub(crate) use aether_ai_formats::api::{
convert_standard_chat_response, convert_standard_cli_response, copy_request_number_field,
copy_request_number_field_as, core_error_background_report_kind,
core_error_default_client_api_format, core_success_background_report_kind,
default_model_directive_mapping_patch, default_model_directive_suffixes,
default_model_for_openai_image_operation, encode_done_sse, encode_json_sse,
encode_kiro_sse_events, endpoint_config_forces_upstream_stream_policy,
enforce_request_body_stream_field, estimate_kiro_tokens, extract_openai_text_content,
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
find_kiro_real_thinking_end_tag, find_kiro_real_thinking_end_tag_at_buffer_end,
find_kiro_real_thinking_start_tag, force_upstream_streaming_for_provider,
gemini_request_is_image_generation, implicit_sync_finalize_report_kind,
is_core_error_finalize_kind, is_matching_stream_http_request, is_matching_stream_request,
is_openai_image_stream_request, is_openai_responses_family_format, is_openai_responses_format,
kiro_crc32, map_claude_stop_reason, map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_gemini_budget, maybe_bridge_standard_sync_json_to_stream,
maybe_build_ai_surface_stream_rewriter,
find_kiro_real_thinking_start_tag, forbid_upstream_streaming_for_provider,
force_upstream_streaming_for_provider, gemini_request_is_image_generation,
implicit_sync_finalize_report_kind, is_core_error_finalize_kind,
is_matching_stream_http_request, is_matching_stream_request, is_openai_image_stream_request,
is_openai_responses_compact_format, is_openai_responses_family_format,
is_openai_responses_format, kiro_crc32, map_claude_stop_reason,
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
maybe_bridge_standard_sync_json_to_stream, maybe_build_ai_surface_stream_rewriter,
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
maybe_build_openai_image_sync_finalize_product,
maybe_build_openai_responses_cross_format_sync_product_from_normalized_payload,
@@ -60,51 +68,60 @@ pub(crate) use aether_ai_formats::api::{
maybe_build_standard_cross_format_sync_product_from_normalized_payload,
maybe_build_standard_same_format_sync_body_from_normalized_payload,
maybe_build_standard_sync_finalize_product_from_normalized_payload, model_directive_base_model,
normalize_api_format_alias, normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request, normalize_openai_image_request,
normalize_openai_image_request_with_options,
model_directive_builtin_suffix_supported_for_source_model,
model_directive_suffix_has_builtin_mapping, normalize_api_format_alias,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request, normalize_openai_image_quality,
normalize_openai_image_request, normalize_openai_image_request_with_options,
normalize_openai_responses_request_to_openai_chat_request,
normalize_provider_private_report_context, normalize_provider_private_response_value,
normalize_standard_request_to_openai_chat_request, openai_image_operation_from_path,
parse_direct_request_body, parse_openai_stop_sequences, parse_openai_tool_result_content,
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
provider_adaptation_descriptor_for_envelope, provider_adaptation_descriptor_for_provider_type,
parse_codex_auth_identity, parse_direct_request_body, parse_model_directive,
parse_model_directive_with_suffixes, parse_openai_stop_sequences,
parse_openai_tool_result_content, prepare_local_success_response_parts,
prepare_local_success_response_parts_owned, project_codex_openai_image_api_request_body,
project_openai_image_api_request_body, provider_adaptation_allows_sync_finalize_envelope,
provider_adaptation_anchor_api_format, provider_adaptation_descriptor_for_envelope,
provider_adaptation_descriptor_for_provider_type,
provider_adaptation_requires_eventstream_accept,
provider_adaptation_should_unwrap_stream_envelope,
provider_private_response_allows_sync_finalize, request_candidate_api_format_preference,
request_candidate_api_formats, request_conversion_kind,
request_conversion_requires_enable_flag, request_path_implies_stream_request,
resolve_claude_stream_spec, resolve_claude_sync_spec,
resolve_execution_runtime_stream_plan_kind, resolve_execution_runtime_sync_plan_kind,
resolve_finalize_stream_rewrite_mode, resolve_gemini_files_stream_spec,
resolve_gemini_files_sync_spec, resolve_gemini_stream_spec, resolve_gemini_sync_spec,
resolve_local_image_stream_spec, resolve_local_image_sync_spec,
resolve_codex_responses_model_capabilities, resolve_execution_runtime_stream_plan_kind,
resolve_execution_runtime_sync_plan_kind, resolve_finalize_stream_rewrite_mode,
resolve_gemini_files_stream_spec, resolve_gemini_files_sync_spec, resolve_gemini_stream_spec,
resolve_gemini_sync_spec, resolve_local_image_stream_spec, resolve_local_image_sync_spec,
resolve_local_same_format_stream_spec, resolve_local_same_format_sync_spec,
resolve_local_video_sync_spec, resolve_openai_chat_max_tokens,
resolve_openai_embedding_sync_spec, resolve_openai_responses_stream_spec,
resolve_openai_responses_sync_spec, resolve_requested_gemini_image_model_for_request,
resolve_requested_openai_image_model_for_request,
resolve_upstream_is_stream_for_provider as resolve_format_upstream_is_stream_for_provider,
resolve_upstream_is_stream_from_endpoint_config, sanitize_request_path,
sanitize_request_path_and_query, sanitize_request_query_string,
stream_body_contains_error_event, supports_stream_execution_decision_kind,
supports_sync_execution_decision_kind, sync_chat_response_conversion_kind,
sync_cli_response_conversion_kind, transform_provider_private_stream_line, value_as_u64,
AiControlPlanRequest, AiSurfaceFinalizeError, AiSurfaceStreamRewriter, CanonicalStreamFrame,
sync_cli_response_conversion_kind, transform_provider_private_stream_line,
validate_openai_provider_request_contract, value_as_u64, AiControlPlanRequest,
AiSurfaceFinalizeError, AiSurfaceStreamRewriter, CanonicalStreamFrame,
ChatGptWebImageRequestError, ClaudeClientEmitter, ClaudeProviderState,
ExecutionRuntimeAuthContext, FinalizeStreamRewriteMode, FormatContext, GeminiClientEmitter,
GeminiImageRequestForOpenAi, GeminiProviderState, KiroToClaudeCliStreamState,
LocalCoreSyncErrorKind, LocalGeminiFilesSpec, LocalOpenAiImageSpec, LocalOpenAiResponsesSpec,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
LocalStandardSourceMode, LocalStandardSpec, LocalSyncReportParts, LocalVideoCreateFamily,
LocalVideoCreateSpec, NormalizedOpenAiImageRequest, OpenAIChatClientEmitter,
OpenAIChatProviderState, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
OpenAiImageNormalizeOptions, OpenAiImageOperation, OpenAiImageRequestForGemini,
OpenAiImageResponseFormat, OpenAiImageStreamState, OpenAiImageSyncFinalizeProduct,
CodexResponsesModelCapabilities, ExecutionRuntimeAuthContext, FinalizeStreamRewriteMode,
FormatContext, GeminiClientEmitter, GeminiImageRequestForOpenAi, GeminiProviderState,
KiroToClaudeCliStreamState, LocalCoreSyncErrorKind, LocalGeminiFilesSpec, LocalOpenAiImageSpec,
LocalOpenAiResponsesSpec, LocalSameFormatProviderFamily, LocalSameFormatProviderSpec,
LocalStandardSourceFamily, LocalStandardSourceMode, LocalStandardSpec, LocalSyncReportParts,
LocalVideoCreateFamily, LocalVideoCreateSpec, NormalizedOpenAiImageRequest,
OpenAIChatClientEmitter, OpenAIChatProviderState, OpenAIResponsesClientEmitter,
OpenAIResponsesProviderState, OpenAiImageNormalizeOptions, OpenAiImageOperation,
OpenAiImageRequestForGemini, OpenAiImageResponseFormat, OpenAiImageStreamState,
OpenAiImageSyncFinalizeProduct, OpenAiProviderRequestFinalization,
ProviderAdaptationDescriptor, ProviderAdaptationSurface, ProviderPrivateStreamNormalizer,
RequestConversionKind, StandardCrossFormatSyncProduct, StandardSyncFinalizeNormalizedProduct,
StreamingStandardFormatMatrix, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
SyncToStreamBridgeOutcome, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND,
ReasoningEffort, RequestConversionKind, ServiceTier, StandardCrossFormatSyncProduct,
StandardSyncFinalizeNormalizedProduct, StreamingStandardFormatMatrix,
SyncChatResponseConversionKind, SyncCliResponseConversionKind, SyncToStreamBridgeOutcome,
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME, CLAUDE_CHAT_STREAM_PLAN_KIND,
CLAUDE_CHAT_STREAM_SUCCESS_REPORT_KIND, CLAUDE_CHAT_SYNC_ERROR_REPORT_KIND,
CLAUDE_CHAT_SYNC_FINALIZE_REPORT_KIND, CLAUDE_CHAT_SYNC_PLAN_KIND,
CLAUDE_CHAT_SYNC_SUCCESS_REPORT_KIND, CLAUDE_CLI_STREAM_PLAN_KIND,
@@ -125,14 +142,15 @@ pub(crate) use aether_ai_formats::api::{
GEMINI_FILES_DELETE_PLAN_KIND, GEMINI_FILES_DOWNLOAD_PLAN_KIND, GEMINI_FILES_GET_PLAN_KIND,
GEMINI_FILES_LIST_PLAN_KIND, GEMINI_FILES_UPLOAD_PLAN_KIND, GEMINI_VIDEO_CANCEL_SYNC_PLAN_KIND,
GEMINI_VIDEO_CREATE_SYNC_FINALIZE_REPORT_KIND, GEMINI_VIDEO_CREATE_SYNC_PLAN_KIND,
KIRO_ENVELOPE_NAME, KIRO_MAX_THINKING_BUFFER, OPENAI_CHAT_STREAM_PLAN_KIND,
OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND, OPENAI_CHAT_SYNC_ERROR_REPORT_KIND,
OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND, OPENAI_CHAT_SYNC_PLAN_KIND,
OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND, OPENAI_EMBEDDING_SYNC_PLAN_KIND,
OPENAI_IMAGE_STREAM_PLAN_KIND, OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND,
OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND, OPENAI_IMAGE_SYNC_PLAN_KIND,
OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND, OPENAI_RERANK_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
KIRO_ENVELOPE_NAME, KIRO_MAX_THINKING_BUFFER, MODEL_DIRECTIVE_API_FORMATS,
OPENAI_CHAT_STREAM_PLAN_KIND, OPENAI_CHAT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_CHAT_SYNC_ERROR_REPORT_KIND, OPENAI_CHAT_SYNC_FINALIZE_REPORT_KIND,
OPENAI_CHAT_SYNC_PLAN_KIND, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND,
OPENAI_EMBEDDING_SYNC_PLAN_KIND, OPENAI_IMAGE_STREAM_PLAN_KIND,
OPENAI_IMAGE_STREAM_SUCCESS_REPORT_KIND, OPENAI_IMAGE_SYNC_FINALIZE_REPORT_KIND,
OPENAI_IMAGE_SYNC_PLAN_KIND, OPENAI_IMAGE_SYNC_SUCCESS_REPORT_KIND,
OPENAI_RERANK_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_ERROR_REPORT_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_FINALIZE_REPORT_KIND, OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND,
OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND, OPENAI_RESPONSES_STREAM_PLAN_KIND,
+88
View File
@@ -92,6 +92,7 @@ pub(crate) struct CandidatePageCacheKey {
preselection_mode: &'static str,
use_api_format_alias_match: bool,
client_session_affinity_hash: String,
model_directive_policy_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -140,6 +141,7 @@ impl CandidatePageCacheKey {
preselection_mode: &'static str,
use_api_format_alias_match: bool,
client_session_affinity: Option<&ClientSessionAffinity>,
model_directive_policy_hash: &str,
) -> Self {
Self {
requested_model: normalize_text_key(requested_model),
@@ -153,6 +155,7 @@ impl CandidatePageCacheKey {
preselection_mode,
use_api_format_alias_match,
client_session_affinity_hash: client_session_affinity_key(client_session_affinity),
model_directive_policy_hash: normalize_text_key(model_directive_policy_hash),
}
}
}
@@ -171,6 +174,7 @@ impl CandidateResolvedPageCacheKey {
preselection_mode: &'static str,
use_api_format_alias_match: bool,
client_session_affinity: Option<&ClientSessionAffinity>,
model_directive_policy_hash: &str,
resolution_mode: AiCandidateResolutionMode,
) -> Self {
Self {
@@ -186,6 +190,7 @@ impl CandidateResolvedPageCacheKey {
preselection_mode,
use_api_format_alias_match,
client_session_affinity,
model_directive_policy_hash,
),
resolution_mode: resolution_mode_name(resolution_mode),
}
@@ -569,6 +574,7 @@ mod tests {
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let different_user = CandidatePageCacheKey::new(
"gpt-4o",
@@ -582,6 +588,7 @@ mod tests {
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let different_model = CandidatePageCacheKey::new(
"gpt-4.1",
@@ -595,6 +602,7 @@ mod tests {
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let different_format = CandidatePageCacheKey::new(
"gpt-4o",
@@ -608,6 +616,7 @@ mod tests {
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let different_capabilities = CandidatePageCacheKey::new(
"gpt-4o",
@@ -621,11 +630,90 @@ mod tests {
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let same_policy = CandidatePageCacheKey::new(
"gpt-4o",
"openai:chat",
true,
&auth_a,
Some(&json!({"vision": true})),
None,
Some("bearer"),
7,
"provider_endpoint_key_model",
true,
None,
"policy-a",
);
let different_policy = CandidatePageCacheKey::new(
"gpt-4o",
"openai:chat",
true,
&auth_a,
Some(&json!({"vision": true})),
None,
Some("bearer"),
7,
"provider_endpoint_key_model",
true,
None,
"policy-b",
);
assert_eq!(base, same_policy);
assert_ne!(base, different_user);
assert_ne!(base, different_model);
assert_ne!(base, different_format);
assert_ne!(base, different_capabilities);
assert_ne!(base, different_policy);
let resolved_base = CandidateResolvedPageCacheKey::new(
"gpt-4o",
"openai:chat",
true,
&auth_a,
Some(&json!({"vision": true})),
None,
Some("bearer"),
7,
"provider_endpoint_key_model",
true,
None,
"policy-a",
AiCandidateResolutionMode::Standard,
);
let resolved_same_policy = CandidateResolvedPageCacheKey::new(
"gpt-4o",
"openai:chat",
true,
&auth_a,
Some(&json!({"vision": true})),
None,
Some("bearer"),
7,
"provider_endpoint_key_model",
true,
None,
"policy-a",
AiCandidateResolutionMode::Standard,
);
let resolved_different_policy = CandidateResolvedPageCacheKey::new(
"gpt-4o",
"openai:chat",
true,
&auth_a,
Some(&json!({"vision": true})),
None,
Some("bearer"),
7,
"provider_endpoint_key_model",
true,
None,
"policy-b",
AiCandidateResolutionMode::Standard,
);
assert_eq!(resolved_base, resolved_same_policy);
assert_ne!(resolved_base, resolved_different_policy);
}
}
@@ -307,7 +307,9 @@ impl ClientSessionScopeAdapter for GenericSessionScopeAdapter {
}
fn extract_scope(&self, request: &ClientSessionRequest<'_>) -> Option<ClientSessionScope> {
if let Some(root_session) = header_value_str(request.headers, "session_id")
if let Some(root_session) = header_value_str(request.headers, "session-id")
.or_else(|| header_value_str(request.headers, "thread-id"))
.or_else(|| header_value_str(request.headers, "session_id"))
.or_else(|| header_value_str(request.headers, "conversation_id"))
{
return Some(ClientSessionScope::new(
@@ -367,7 +369,9 @@ impl ClientSessionScopeAdapter for CodexSessionScopeAdapter {
}
fn extract_scope(&self, request: &ClientSessionRequest<'_>) -> Option<ClientSessionScope> {
header_value_str(request.headers, "session_id")
header_value_str(request.headers, "session-id")
.or_else(|| header_value_str(request.headers, "thread-id"))
.or_else(|| header_value_str(request.headers, "session_id"))
.or_else(|| header_value_str(request.headers, "conversation_id"))
.map(|root_session| {
ClientSessionScope::new(
@@ -841,9 +845,10 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
http::header::USER_AGENT,
HeaderValue::from_static("codex-tui/0.122.0"),
HeaderValue::from_static("codex_cli_rs/0.144.1"),
);
headers.insert("session_id", HeaderValue::from_static("codex-session"));
headers.insert("session-id", HeaderValue::from_static("codex-session"));
headers.insert("thread-id", HeaderValue::from_static("codex-thread"));
let affinity =
client_session_affinity_from_request(&headers, None).expect("affinity should build");
@@ -860,7 +865,7 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
http::header::USER_AGENT,
HeaderValue::from_static("codex-tui/0.122.0"),
HeaderValue::from_static("codex_cli_rs/0.144.1"),
);
headers.insert(
"x-client-request-id",
+67 -27
View File
@@ -70,12 +70,10 @@ pub(crate) async fn request_model_local_rejection(
) {
if !contains_string(allowed_models, requested_model)
&& !model_directive_base_model_is_allowed_for_request(
state,
decision,
requested_model,
allowed_models,
)
.await
&& !request_model_resolves_to_allowed_model(
state,
decision,
@@ -417,18 +415,11 @@ fn estimate_text_tokens(text: &str) -> u64 {
chars.div_ceil(4).max(1)
}
async fn model_directive_base_model_is_allowed_for_request(
state: &AppState,
fn model_directive_base_model_is_allowed_for_request(
decision: &GatewayControlDecision,
requested_model: &str,
allowed_models: &[String],
) -> bool {
let Some(base_model) = crate::ai_serving::model_directive_base_model(requested_model) else {
return false;
};
if !contains_string(allowed_models, &base_model) {
return false;
}
let Some(client_api_format) = decision
.auth_endpoint_signature
.as_deref()
@@ -438,12 +429,12 @@ async fn model_directive_base_model_is_allowed_for_request(
return false;
};
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
if crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
&api_format,
Some(requested_model),
)
.await
let resolution = decision
.model_directive_policy
.resolve_reasoning(&api_format, Some(requested_model));
if resolution
.base_model()
.is_some_and(|base_model| contains_string(allowed_models, base_model))
{
return true;
}
@@ -467,13 +458,10 @@ async fn request_model_resolves_to_allowed_model(
};
for api_format in candidate_api_formats_for_model_resolution(&client_api_format) {
let enable_model_directives =
crate::system_features::reasoning_model_directive_enabled_for_api_format_and_model(
state,
&api_format,
Some(requested_model),
)
.await;
let resolution = decision
.model_directive_policy
.resolve_reasoning(&api_format, Some(requested_model));
let routing_model = resolution.base_model().unwrap_or(requested_model);
let rows = state
.list_minimal_candidate_selection_rows_for_api_format(&api_format)
.await?;
@@ -482,18 +470,18 @@ async fn request_model_resolves_to_allowed_model(
.filter(|row| {
aether_scheduler_core::row_supports_requested_model_with_model_directives(
row,
requested_model,
routing_model,
&api_format,
enable_model_directives,
false,
)
})
.collect::<Vec<_>>();
let Some(resolved_global_model) =
aether_scheduler_core::resolve_requested_global_model_name_with_model_directives(
&matching_rows,
requested_model,
routing_model,
&api_format,
enable_model_directives,
false,
)
else {
continue;
@@ -877,6 +865,58 @@ mod tests {
);
}
#[tokio::test]
async fn model_rejection_reuses_request_policy_snapshot_for_directive_base_model() {
let state = state_with_rows(Vec::new());
let mut decision = decision_with_allowed_models(vec!["gpt-5.6-sol".to_string()]);
decision.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
None,
);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body = Bytes::from_static(br#"{"model":"gpt-5.6-sol-high","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(rejection, None);
}
#[tokio::test]
async fn model_rejection_uses_custom_policy_suffix_for_base_model_authorization() {
let state = state_with_rows(Vec::new());
let mut decision = decision_with_allowed_models(vec!["deployment-alias".to_string()]);
decision.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::from_config_values(
Some(&json!(true)),
Some(&json!({
"reasoning_effort": {
"api_formats": {
"openai:chat": {
"suffixes": ["VendorFuture"],
"mappings": {
"VendorFuture": { "reasoning_effort": "high" }
}
}
}
}
})),
);
let uri: Uri = "/v1/chat/completions".parse().expect("uri should parse");
let body =
Bytes::from_static(br#"{"model":"deployment-alias-VendorFuture","messages":[]}"#);
let rejection =
request_model_local_rejection(&state, Some(&decision), &uri, &json_headers(), &body)
.await
.expect("model rejection should resolve");
assert_eq!(rejection, None);
}
#[tokio::test]
async fn positive_balance_allows_unbounded_output_request_without_cost_estimate() {
let context = billing_context_with_pricing(
@@ -25,6 +25,7 @@ pub(crate) struct GatewayControlDecision {
pub(crate) auth_context: Option<GatewayControlAuthContext>,
pub(crate) admin_principal: Option<GatewayAdminPrincipalContext>,
pub(crate) local_auth_rejection: Option<GatewayLocalAuthRejection>,
pub(crate) model_directive_policy: crate::system_features::ModelDirectivePolicySnapshot,
}
impl GatewayControlDecision {
@@ -47,6 +48,7 @@ impl GatewayControlDecision {
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -131,6 +133,7 @@ impl ClassifiedRoute {
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
}
@@ -146,6 +149,10 @@ pub(crate) async fn resolve_control_route(
return Ok(None);
};
decision.public_query_string = uri.query().map(ToOwned::to_owned);
if decision.route_class.as_deref() == Some("ai_public") {
decision.model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(state).await;
}
match resolve_control_decision_auth(state, headers, uri, trace_id, decision).await? {
ControlDecisionAuthResolution::Resolved(decision) => Ok(Some(decision)),
@@ -197,6 +204,15 @@ pub(super) fn detect_public_models_auth_signature(uri: &Uri, headers: &http::Hea
return "gemini:generate_content".to_string();
}
let has_codex_client_version = uri.path() == "/v1/models"
&& uri.query().is_some_and(|query| {
url::form_urlencoded::parse(query.as_bytes())
.any(|(key, value)| key == "client_version" && !value.trim().is_empty())
});
if has_codex_client_version {
return "openai:responses".to_string();
}
if uri.path().starts_with("/v1beta/models") {
return "gemini:generate_content".to_string();
}
@@ -20,6 +20,39 @@ fn classifies_models_list_as_public_support_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_codex_models_list_with_responses_auth_signature() {
let headers = headers(&[("authorization", "Bearer sk-test")]);
let uri: Uri = "/v1/models?client_version=0.144.1"
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(decision.route_class.as_deref(), Some("public_support"));
assert_eq!(decision.route_family.as_deref(), Some("models"));
assert_eq!(decision.route_kind.as_deref(), Some("list"));
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("openai:responses")
);
}
#[test]
fn empty_codex_client_version_keeps_standard_openai_models_signature() {
let headers = headers(&[("authorization", "Bearer sk-test")]);
let uri: Uri = "/v1/models?client_version="
.parse()
.expect("uri should parse");
let decision =
classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify");
assert_eq!(
decision.auth_endpoint_signature.as_deref(),
Some("openai:chat")
);
}
#[test]
fn classifies_v1beta_models_as_gemini_public_support_route() {
let headers = headers(&[]);
@@ -532,6 +532,88 @@ impl GatewayDataState {
Ok(updated)
}
pub(crate) async fn upsert_provider_catalog_key_upstream_metadata_namespace(
&self,
key_id: &str,
namespace: &str,
value: &serde_json::Value,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => {
repository
.upsert_key_upstream_metadata_namespace(
key_id,
namespace,
value,
updated_at_unix_secs,
)
.await
}
None => Ok(false),
}?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_model_fetch_state(
&self,
key_id: &str,
allowed_models: Option<&serde_json::Value>,
last_models_fetch_at_unix_secs: Option<u64>,
last_models_fetch_error: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => {
repository
.update_key_model_fetch_state(
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
last_models_fetch_error,
updated_at_unix_secs,
)
.await
}
None => Ok(false),
}?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_model_fetch_success(
&self,
key_id: &str,
allowed_models: Option<&serde_json::Value>,
last_models_fetch_at_unix_secs: u64,
upstream_metadata_updates: &[aether_data_contracts::repository::provider_catalog::ProviderCatalogUpstreamMetadataNamespaceUpdate],
updated_at_unix_secs: Option<u64>,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => {
repository
.update_key_model_fetch_success(
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
upstream_metadata_updates,
updated_at_unix_secs,
)
.await
}
None => Ok(false),
}?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn delete_provider_catalog_key(
&self,
key_id: &str,
@@ -4172,6 +4172,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
selected_provider_model_name: "gpt-5".to_string(),
supports_streaming: true,
mapping_matched_model: None,
},
provider_api_format: "openai:responses".to_string(),
@@ -4285,6 +4286,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
selected_provider_model_name: "gpt-5".to_string(),
supports_streaming: true,
mapping_matched_model: None,
},
provider_api_format: "openai:chat".to_string(),
+1
View File
@@ -146,6 +146,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
selected_provider_model_name: "gpt-5".to_string(),
supports_streaming: true,
mapping_matched_model: None,
},
transport: Arc::new(crate::ai_serving::GatewayProviderTransportSnapshot {
@@ -1042,6 +1042,7 @@ fn grok_stream_terminal_summary(
finish_reason: Some("stop".to_string()),
response_id: None,
model: plan.model_name.clone(),
provider_actual_service_tier: None,
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
@@ -356,8 +356,7 @@ impl IntoResponse for ExecutionRuntimeAppError {
return build_overloaded_response(&self.0.to_string());
}
ExecutionRuntimeServerError::Transport(
ExecutionRuntimeTransportError::StreamUnsupported
| ExecutionRuntimeTransportError::RequestBodyRequired
ExecutionRuntimeTransportError::RequestBodyRequired
| ExecutionRuntimeTransportError::BodyDecode(_)
| ExecutionRuntimeTransportError::UnsupportedContentEncoding(_)
| ExecutionRuntimeTransportError::ProxyUnsupported
@@ -394,7 +393,9 @@ mod tests {
build_execution_runtime_router_with_request_concurrency_limit,
build_execution_runtime_router_with_request_gates, DISTRIBUTED_REQUEST_GATE_NAME,
};
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_contracts::{
ExecutionPlan, ExecutionTimeouts, RequestBody, StreamFrame, StreamFrameType,
};
use aether_runtime_state::{
MemoryRuntimeStateConfig, RuntimeSemaphore, RuntimeSemaphoreConfig, RuntimeState,
};
@@ -456,6 +457,43 @@ mod tests {
}
}
#[tokio::test]
async fn execution_runtime_stream_endpoint_carries_non_stream_upstream_plan() {
let upstream = Router::new().route(
"/sync-json",
any(|| async { axum::Json(serde_json::json!({"ok": true})) }),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let runtime = build_execution_runtime_router_with_request_concurrency_limit(None);
let (runtime_url, runtime_handle) = start_server(runtime).await;
let mut plan = stream_plan(format!("{upstream_url}/sync-json"));
plan.stream = false;
let response = reqwest::Client::new()
.post(format!("{runtime_url}/v1/execute/stream"))
.json(&plan)
.send()
.await
.expect("execution request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response.text().await.expect("frame body should read");
let frame_types = body
.lines()
.map(|line| {
serde_json::from_str::<StreamFrame>(line)
.expect("execution runtime frame should decode")
.frame_type
})
.collect::<Vec<_>>();
assert!(frame_types.contains(&StreamFrameType::Headers));
assert!(frame_types.contains(&StreamFrameType::Data));
assert!(frame_types.contains(&StreamFrameType::Eof));
runtime_handle.abort();
upstream_handle.abort();
}
#[tokio::test]
async fn execution_runtime_rejects_second_in_flight_stream_request_with_overload() {
let upstream_hits = Arc::new(AtomicUsize::new(0));
@@ -851,6 +851,9 @@ fn merge_stream_terminal_summary(
if current_summary.model.is_none() {
current_summary.model = observed.model;
}
if observed.provider_actual_service_tier.is_some() {
current_summary.provider_actual_service_tier = observed.provider_actual_service_tier;
}
current_summary.observed_finish |= observed.observed_finish;
current_summary.unknown_event_count = current_summary
.unknown_event_count
@@ -676,6 +676,14 @@ fn should_buffer_non_stream_response(
return false;
}
if report_context
.get("upstream_is_stream")
.and_then(Value::as_bool)
== Some(false)
{
return true;
}
headers
.get("content-length")
.and_then(|value| value.trim().parse::<u64>().ok())
@@ -1134,22 +1142,36 @@ mod tests {
}
#[test]
fn buffers_non_sse_response_only_when_content_length_is_known() {
let report_context = serde_json::json!({
fn buffers_declared_non_stream_responses_without_relying_on_content_length() {
let streaming_context = serde_json::json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:chat",
"upstream_is_stream": true,
});
let non_stream_context = serde_json::json!({
"provider_api_format": "openai:image",
"client_api_format": "openai:responses",
"upstream_is_stream": false,
});
assert!(!should_buffer_non_stream_response(
&BTreeMap::from([("content-type".into(), "application/json".into())]),
&report_context
&streaming_context
));
assert!(should_buffer_non_stream_response(
&BTreeMap::from([("content-type".into(), "application/json".into())]),
&non_stream_context
));
assert!(should_buffer_non_stream_response(
&BTreeMap::from([
("content-type".into(), "application/json".into()),
("content-length".into(), "128".into()),
]),
&report_context
&streaming_context
));
assert!(!should_buffer_non_stream_response(
&BTreeMap::from([("content-type".into(), "text/event-stream".into())]),
&non_stream_context
));
}
@@ -1567,33 +1589,48 @@ mod tests {
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let generated_image = "a".repeat(32 * 1024);
let expected_image = generated_image.clone();
let server = tokio::spawn(async move {
let app = Router::new().route(
"/responses",
post(|| async {
let body = serde_json::json!({
"created": 1776971267_u64,
"data": [{
"b64_json": "aGVsbG8="
}],
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50,
"input_tokens_details": {
"text_tokens": 10,
"image_tokens": 40
"/images/generations",
post(move || {
let generated_image = generated_image.clone();
async move {
let body = serde_json::json!({
"created": 1776971267_u64,
"data": [{
"b64_json": generated_image
}],
"usage": {
"total_tokens": 100,
"input_tokens": 50,
"output_tokens": 50,
"input_tokens_details": {
"text_tokens": 10,
"image_tokens": 40
}
}
}
});
let mut response = axum::http::Response::new(Body::from(
serde_json::to_vec(&body).expect("json should encode"),
));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response
});
let encoded = serde_json::to_vec(&body).expect("json should encode");
let chunks = encoded
.chunks(4096)
.map(Bytes::copy_from_slice)
.collect::<Vec<_>>();
let chunked_body = stream! {
for chunk in chunks {
yield Ok::<Bytes, Infallible>(chunk);
tokio::task::yield_now().await;
}
};
let mut response =
axum::http::Response::new(Body::from_stream(chunked_body));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response
}
}),
);
axum::serve(listener, app)
@@ -1611,16 +1648,15 @@ mod tests {
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: format!("http://{addr}/responses"),
url: format!("http://{addr}/images/generations"),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody::from_json(serde_json::json!({
"model": "gpt-image-1",
"prompt": "poster",
"stream": true
"prompt": "poster"
})),
stream: true,
stream: false,
client_api_format: "openai:image".to_string(),
provider_api_format: "openai:image".to_string(),
model_name: Some("gpt-image-1".into()),
@@ -1673,7 +1709,8 @@ mod tests {
let bridged_text = String::from_utf8(bridged_body).expect("bridged body should be utf8");
assert!(bridged_text.contains("event: image_generation.completed"));
assert!(bridged_text.contains("\"type\":\"image_generation.completed\""));
assert!(bridged_text.contains("\"b64_json\":\"aGVsbG8=\""));
assert!(bridged_text.contains(&format!("\"b64_json\":\"{expected_image}\"")));
assert!(bridged_text.len() > 32 * 1024);
assert!(bridged_text.contains("\"total_tokens\":100"));
let eof_frame = frames
@@ -43,6 +43,7 @@ fn missing_exact_provider_request_payload(decision_kind: &str) -> AiExecutionDec
request_id: Some("req_123".to_string()),
candidate_id: Some("cand_123".to_string()),
provider_name: Some("provider".to_string()),
provider_type: None,
provider_id: Some("provider_id".to_string()),
endpoint_id: Some("endpoint_id".to_string()),
key_id: Some("key_id".to_string()),
@@ -57,8 +57,9 @@ const TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
const DEFAULT_TUNNEL_TIMEOUT_MS: u64 = 60_000;
const DEFAULT_STREAM_FIRST_BYTE_TIMEOUT_MS: u64 = 30_000;
const DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS: u64 = 300_000;
const DEFAULT_CODEX_COMPACT_TOTAL_TIMEOUT_MS: u64 = 1_200_000;
const MIN_TUNNEL_TIMEOUT_SECS: u64 = 1;
const MAX_TUNNEL_TIMEOUT_SECS: u64 = 300;
const MAX_TUNNEL_TIMEOUT_SECS: u64 = 1_200;
const DIRECT_REQWEST_H2_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_H2_CLIENT_SHARDS";
const DIRECT_REQWEST_CLIENT_SHARDS_ENV: &str = "AETHER_GATEWAY_DIRECT_REQWEST_CLIENT_SHARDS";
const DIRECT_REQWEST_H2_TARGET_STREAMS_PER_CLIENT_ENV: &str =
@@ -512,8 +513,6 @@ pub(crate) fn format_hyper_error_chain(err: &dyn std::error::Error) -> String {
#[derive(Debug, Error)]
pub(crate) enum ExecutionRuntimeTransportError {
#[error("stream execution is not supported for this plan")]
StreamUnsupported,
#[error("request body must contain json_body or body_bytes_b64")]
RequestBodyRequired,
#[error("request body base64 is invalid: {0}")]
@@ -681,10 +680,6 @@ impl DirectSyncExecutionRuntime {
&self,
plan: &ExecutionPlan,
) -> Result<DirectUpstreamStreamExecution, ExecutionRuntimeTransportError> {
if !plan.stream {
return Err(ExecutionRuntimeTransportError::StreamUnsupported);
}
let build_body_started_at = Instant::now();
let body_bytes = build_request_body(plan)?;
observe_gateway_stage_ms(
@@ -835,6 +830,7 @@ fn build_stream_summary_report_context(plan: &ExecutionPlan) -> Value {
"provider_api_format": plan.provider_api_format,
"client_api_format": plan.client_api_format,
"model": plan.model_name,
"upstream_is_stream": plan.stream,
})
}
@@ -2125,20 +2121,17 @@ pub(crate) fn build_request_body(
Vec::new()
};
if should_gzip_request_body(plan) && plan.body.json_body.is_some() {
body_bytes = gzip_bytes(&body_bytes)?;
if plan.body.json_body.is_some() {
body_bytes = match normalize_content_encoding(plan.content_encoding.as_deref()).as_deref() {
Some("gzip") => gzip_bytes(&body_bytes)?,
Some("zstd") => zstd_bytes(&body_bytes)?,
_ => body_bytes,
};
}
Ok(body_bytes)
}
fn should_gzip_request_body(plan: &ExecutionPlan) -> bool {
matches!(
normalize_content_encoding(plan.content_encoding.as_deref()).as_deref(),
Some("gzip")
)
}
fn normalize_content_encoding(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@@ -2156,6 +2149,11 @@ fn gzip_bytes(body_bytes: &[u8]) -> Result<Vec<u8>, ExecutionRuntimeTransportErr
.map_err(|err| ExecutionRuntimeTransportError::RelayError(err.to_string()))
}
fn zstd_bytes(body_bytes: &[u8]) -> Result<Vec<u8>, ExecutionRuntimeTransportError> {
zstd::stream::encode_all(std::io::Cursor::new(body_bytes), 3)
.map_err(|err| ExecutionRuntimeTransportError::RelayError(err.to_string()))
}
fn build_relay_client(
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
) -> Result<reqwest::Client, ExecutionRuntimeTransportError> {
@@ -2222,11 +2220,17 @@ fn resolve_non_stream_total_timeout(plan: &ExecutionPlan) -> Option<Duration> {
if plan.stream {
return None;
}
let default_timeout_ms =
if crate::ai_serving::is_openai_responses_compact_format(&plan.provider_api_format) {
DEFAULT_CODEX_COMPACT_TOTAL_TIMEOUT_MS
} else {
DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS
};
let timeout_ms = plan
.timeouts
.as_ref()
.and_then(|timeouts| timeouts.total_ms)
.unwrap_or(DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS);
.unwrap_or(default_timeout_ms);
Some(Duration::from_millis(timeout_ms.max(1)))
}
@@ -3552,7 +3556,7 @@ pub(crate) fn build_request_headers(
let mut out = HeaderMap::new();
let normalized_content_encoding = normalize_content_encoding(content_encoding);
if let Some(encoding) = normalized_content_encoding.as_deref() {
if encoding != "gzip" && !allow_passthrough_content_encoding {
if !matches!(encoding, "gzip" | "zstd") && !allow_passthrough_content_encoding {
return Err(ExecutionRuntimeTransportError::UnsupportedContentEncoding(
encoding.to_string(),
));
@@ -4637,6 +4641,25 @@ mod tests {
assert_eq!(timeout, std::time::Duration::from_secs(300));
}
#[test]
fn codex_compact_uses_the_full_unary_timeout_by_default() {
let mut plan = tunnel_timeout_plan(false);
plan.provider_api_format = "openai:responses:compact".to_string();
plan.timeouts = None;
let timeout = resolve_non_stream_total_timeout(&plan)
.expect("Codex Compact should have a total timeout");
let meta = build_direct_tunnel_request_meta(
&plan,
&reqwest::header::HeaderMap::new(),
ExecutionTransportControls::default(),
);
assert_eq!(timeout, std::time::Duration::from_secs(1_200));
assert_eq!(meta.request_timeout_ms, Some(1_200_000));
assert_eq!(meta.timeout, 1_200);
}
#[test]
fn tunnel_request_meta_uses_non_stream_default_instead_of_first_byte_default() {
let mut plan = tunnel_timeout_plan(false);
@@ -6323,13 +6346,21 @@ mod tests {
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let mut decoder = flate2::read::GzDecoder::new(body.as_ref());
let mut decoded = String::new();
decoder
.read_to_string(&mut decoded)
.expect("gzip body should decode");
let decoded = match header_encoding.as_str() {
"gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body.as_ref());
let mut decoded = Vec::new();
decoder
.read_to_end(&mut decoded)
.expect("gzip body should decode");
decoded
}
"zstd" => zstd::stream::decode_all(std::io::Cursor::new(body.as_ref()))
.expect("zstd body should decode"),
encoding => panic!("unexpected content encoding: {encoding}"),
};
let decoded_json: serde_json::Value =
serde_json::from_str(&decoded).expect("decoded json should parse");
serde_json::from_slice(&decoded).expect("decoded json should parse");
(
axum::http::StatusCode::OK,
Json(json!({
@@ -6346,45 +6377,47 @@ mod tests {
});
let execution_runtime = DirectSyncExecutionRuntime::new();
let result = execution_runtime
.execute_sync(&ExecutionPlan {
request_id: "req-gzip-1".into(),
candidate_id: Some("cand-1".into()),
provider_name: Some("openai".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: format!("http://{addr}/chat"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: Some("gzip".into()),
body: RequestBody::from_json(json!({"model": "gpt-4.1"})),
stream: false,
client_api_format: "openai:chat".into(),
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
})
.await
.expect("gzip sync execution should succeed");
for encoding in ["gzip", "zstd"] {
let result = execution_runtime
.execute_sync(&ExecutionPlan {
request_id: format!("req-{encoding}-1"),
candidate_id: Some("cand-1".into()),
provider_name: Some("openai".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: format!("http://{addr}/chat"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: Some(encoding.into()),
body: RequestBody::from_json(json!({"model": "gpt-4.1"})),
stream: false,
client_api_format: "openai:chat".into(),
provider_api_format: "openai:chat".into(),
model_name: Some("gpt-4.1".into()),
proxy: None,
transport_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
})
.await
.expect("compressed sync execution should succeed");
assert_eq!(result.status_code, 200);
assert_eq!(
result.body.and_then(|body| body.json_body),
Some(json!({
"content_encoding": encoding,
"body": {"model": "gpt-4.1"},
}))
);
}
server.abort();
assert_eq!(result.status_code, 200);
assert_eq!(
result.body.and_then(|body| body.json_body),
Some(json!({
"content_encoding": "gzip",
"body": {"model": "gpt-4.1"},
}))
);
}
#[tokio::test]
@@ -179,6 +179,7 @@ mod tests {
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -64,7 +64,8 @@ use aether_data_contracts::repository::provider_catalog::{
};
use aether_model_fetch::{
aggregate_models_for_cache, fetch_models_from_transports, json_string_list,
merge_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
model_catalog_upstream_metadata, preset_models_for_provider, selected_models_fetch_endpoints,
upstream_metadata_namespace_updates,
};
use axum::{
body::{to_bytes, Body},
@@ -281,6 +282,7 @@ fn provider_query_attach_model_test_capabilities(
fn provider_query_codex_preset_fallback(
provider: &StoredProviderCatalogProvider,
fetch_error: &str,
) -> Option<ProviderQueryKeyFetchResult> {
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
return None;
@@ -289,12 +291,59 @@ fn provider_query_codex_preset_fallback(
Some(ProviderQueryKeyFetchResult {
models: aggregate_models_for_cache(&models),
error: None,
warning: None,
warning: Some(format!(
"Codex 动态模型目录不可用,已使用内置模型卡:{fetch_error}"
)),
from_cache: false,
has_success: true,
})
}
async fn provider_query_persist_preset_models(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
key: &StoredProviderCatalogKey,
models: &[Value],
) -> Result<(), GatewayError> {
if models.is_empty() {
return Ok(());
}
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
state.app(),
&provider.id,
&key.id,
models,
)
.await;
if let Some(catalog_metadata) = model_catalog_upstream_metadata(&provider.provider_type, models)
{
provider_query_persist_upstream_metadata(state, key, &catalog_metadata).await?;
}
Ok(())
}
async fn provider_query_persist_upstream_metadata(
state: &AdminAppState<'_>,
key: &StoredProviderCatalogKey,
upstream_metadata: &Value,
) -> Result<(), GatewayError> {
let updated_at = current_unix_secs();
for (namespace, value) in
upstream_metadata_namespace_updates(key.upstream_metadata.as_ref(), upstream_metadata)
{
state
.app()
.upsert_provider_catalog_key_upstream_metadata_namespace(
&key.id,
&namespace,
&value,
Some(updated_at),
)
.await?;
}
Ok(())
}
mod model_test;
pub(crate) use self::model_test::{
@@ -439,11 +488,9 @@ async fn provider_query_fetch_models_for_key(
let selected_endpoints = selected_models_fetch_endpoints(endpoints, key);
if selected_endpoints.is_empty() {
if let Some(models) = preset_models_for_provider(&provider.provider_type) {
let models = provider_query_filter_models_for_key(
provider,
key,
aggregate_models_for_cache(&models),
);
let models = aggregate_models_for_cache(&models);
provider_query_persist_preset_models(state, provider, key, &models).await?;
let models = provider_query_filter_models_for_key(provider, key, models);
return Ok(ProviderQueryKeyFetchResult {
models,
error: None,
@@ -492,7 +539,11 @@ async fn provider_query_fetch_models_for_key(
Ok(outcome) => outcome,
Err(err) => {
all_errors.push(err);
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
if let Some(fallback) =
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
{
provider_query_persist_preset_models(state, provider, key, &fallback.models)
.await?;
return Ok(fallback);
}
return Ok(ProviderQueryKeyFetchResult {
@@ -517,20 +568,14 @@ async fn provider_query_fetch_models_for_key(
.await;
}
if let Some(upstream_metadata) = outcome.upstream_metadata.as_ref() {
let merged_metadata =
merge_upstream_metadata(key.upstream_metadata.as_ref(), upstream_metadata);
state
.app()
.update_provider_catalog_key_upstream_metadata(
&key.id,
Some(&merged_metadata),
Some(current_unix_secs()),
)
.await?;
provider_query_persist_upstream_metadata(state, key, upstream_metadata).await?;
}
if unique_models.is_empty() && !all_errors.is_empty() {
if let Some(fallback) = provider_query_codex_preset_fallback(provider) {
if let Some(fallback) =
provider_query_codex_preset_fallback(provider, &all_errors.join("; "))
{
provider_query_persist_preset_models(state, provider, key, &fallback.models).await?;
return Ok(fallback);
}
}
@@ -828,14 +828,12 @@ fn provider_query_resolve_standard_test_upstream_is_stream(
provider_type: &str,
provider_api_format: &str,
) -> bool {
let hard_requires_streaming = crate::ai_serving::force_upstream_streaming_for_provider(
crate::ai_serving::resolve_upstream_is_stream_for_provider(
endpoint_config,
provider_type,
provider_api_format,
);
crate::ai_serving::resolve_upstream_is_stream_from_endpoint_config(
endpoint_config,
false,
hard_requires_streaming,
false,
)
}
@@ -2095,8 +2093,18 @@ async fn provider_query_execute_openai_image_test_candidate(
route_path,
);
let incoming_request_headers = provider_query_extract_request_headers(payload);
let image_request_path = if request_body.get("image").is_some()
|| request_body
.get("images")
.and_then(Value::as_array)
.is_some_and(|images| !images.is_empty())
{
"/v1/images/edits"
} else {
"/v1/images/generations"
};
let mut synthetic_request = http::Request::builder()
.uri("/v1/images/generations")
.uri(image_request_path)
.body(())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
*synthetic_request.headers_mut() = incoming_request_headers;
@@ -2107,11 +2115,18 @@ async fn provider_query_execute_openai_image_test_candidate(
&parts,
&request_body,
None,
provider_query_openai_image_normalize_options(provider_type),
provider_query_openai_image_normalize_options(
provider_type,
Some(candidate.effective_model.as_str()),
),
) else {
return Ok(provider_query_skipped_execution_outcome(
request_body.clone(),
provider_query_openai_image_normalize_failure_message(provider_type, &request_body),
provider_query_openai_image_normalize_failure_message(
provider_type,
Some(candidate.effective_model.as_str()),
&request_body,
),
));
};
@@ -2130,29 +2145,50 @@ async fn provider_query_execute_openai_image_test_candidate(
.provider_type
.trim()
.eq_ignore_ascii_case("codex");
let mut provider_request_body = if is_chatgpt_web {
match crate::ai_serving::build_chatgpt_web_image_request_body(&parts, &request_body, None) {
Ok(body) => body,
Err(err) => err.to_error_json(),
}
} else if is_codex || is_grok {
crate::ai_serving::build_openai_image_provider_request_body(&normalized_request)
let upstream_is_stream = crate::ai_serving::resolve_upstream_is_stream_for_provider(
transport.endpoint.config.as_ref(),
transport.provider.provider_type.as_str(),
"openai:image",
request_body
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(false),
false,
);
let provider_request_body = if is_chatgpt_web {
Some(
match crate::ai_serving::build_chatgpt_web_image_request_body(
&parts,
&request_body,
None,
) {
Ok(body) => body,
Err(err) => err.to_error_json(),
},
)
} else if is_codex {
crate::ai_serving::build_codex_openai_image_api_provider_request_body(
&normalized_request,
Some(candidate.effective_model.as_str()),
upstream_is_stream,
)
} else if is_grok {
Some(crate::ai_serving::build_openai_image_provider_request_body(
&normalized_request,
))
} else {
crate::ai_serving::build_openai_image_api_provider_request_body(
&normalized_request,
Some(candidate.effective_model.as_str()),
upstream_is_stream,
)
};
if !is_chatgpt_web {
crate::ai_serving::apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
transport.provider.provider_type.as_str(),
"openai:image",
transport.endpoint.body_rules.as_ref(),
Some(candidate.key.id.as_str()),
);
}
let Some(provider_request_body) = provider_request_body else {
return Ok(provider_query_skipped_execution_outcome(
request_body,
"Provider request is outside the Codex Images contract",
));
};
let oauth_auth = state.resolve_local_oauth_header_auth(&transport).await?;
let Some((auth_header, auth_value)) =
crate::provider_transport::resolve_openai_image_auth(&transport).or(oauth_auth)
@@ -2184,10 +2220,12 @@ async fn provider_query_execute_openai_image_test_candidate(
headers: &parts.headers,
auth_header: &auth_header,
auth_value: &auth_value,
accept: if is_codex || is_chatgpt_web {
"text/event-stream"
accept: if is_codex {
None
} else if upstream_is_stream {
Some("text/event-stream")
} else {
"application/json"
Some("application/json")
},
header_rules: transport.endpoint.header_rules.as_ref(),
provider_request_body: &provider_request_body,
@@ -2212,7 +2250,7 @@ async fn provider_query_execute_openai_image_test_candidate(
request_headers.insert("x-aether-chatgpt-web-image".to_string(), "1".to_string());
} else if is_grok {
} else {
crate::ai_serving::apply_codex_openai_responses_special_headers(
crate::ai_serving::apply_codex_openai_special_headers(
&mut request_headers,
&provider_request_body,
&parts.headers,
@@ -2251,13 +2289,9 @@ async fn provider_query_execute_openai_image_test_candidate(
};
let request_url = provider_query_openai_image_test_upstream_url(
&transport,
Some(parts.uri.path()),
Some(image_request_path),
parts.uri.query(),
);
let upstream_is_stream = provider_request_body
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(true);
let plan = ExecutionPlan {
request_id: trace_id.to_string(),
@@ -3034,6 +3068,37 @@ async fn provider_query_execute_standard_test_candidate(
upstream_is_stream,
require_body_stream_field,
);
let source_model = provider_query_request_body_model(&request_body, request_model);
let codex_model_capabilities = crate::ai_serving::codex_model_capabilities_for_transport(
&transport,
provider_api_format,
request_model,
source_model,
);
if matches!(
normalized_provider_api_format.as_str(),
"openai:chat" | "openai:responses" | "openai:responses:compact"
) && crate::ai_serving::finalize_openai_provider_request_with_codex_model_capabilities(
&mut provider_request_body,
crate::ai_serving::OpenAiProviderRequestFinalization {
source_api_format: client_api_format,
provider_api_format,
provider_type: transport.provider.provider_type.as_str(),
provider_model: request_model,
source_model,
body_rules: transport.endpoint.body_rules.as_ref(),
upstream_is_stream,
require_body_stream_field,
},
codex_model_capabilities.as_ref(),
)
.is_err()
{
return Ok(provider_query_skipped_execution_outcome(
provider_request_body,
"Provider request body violates the OpenAI provider contract",
));
}
if crate::provider_transport::is_gemini_cli_provider_transport(&transport)
&& normalized_provider_api_format == "gemini:generate_content"
{
@@ -3256,8 +3321,8 @@ async fn provider_query_execute_standard_test_candidate(
response_body: None,
});
}
if crate::ai_serving::is_openai_responses_format(provider_api_format) {
crate::ai_serving::apply_codex_openai_responses_special_headers(
if crate::ai_serving::is_openai_responses_family_format(provider_api_format) {
crate::ai_serving::apply_codex_openai_special_headers(
&mut request_headers,
&provider_request_body,
&parts.headers,
@@ -3266,9 +3331,17 @@ async fn provider_query_execute_standard_test_candidate(
Some(trace_id),
transport.key.decrypted_auth_config.as_deref(),
);
crate::provider_transport::apply_local_auth_config_header_overrides(
let final_provider_model = provider_request_body
.get("model")
.and_then(Value::as_str)
.unwrap_or(request_model);
crate::ai_serving::apply_codex_openai_responses_lite_header_with_capabilities(
&mut request_headers,
transport.key.decrypted_auth_config.as_deref(),
transport.provider.provider_type.as_str(),
provider_api_format,
final_provider_model,
source_model,
codex_model_capabilities.as_ref(),
);
}
if !uses_vertex_query_auth {
@@ -9,16 +9,19 @@ pub(super) struct ProviderQueryOpenAiImageTestCapability(AdminProviderOpenAiImag
pub(super) fn provider_query_openai_image_test_capability(
provider_type: &str,
provider_model: Option<&str>,
) -> ProviderQueryOpenAiImageTestCapability {
ProviderQueryOpenAiImageTestCapability(admin_provider_openai_image_test_capability(
provider_type,
provider_model,
))
}
pub(super) fn provider_query_openai_image_normalize_options(
provider_type: &str,
provider_model: Option<&str>,
) -> crate::ai_serving::OpenAiImageNormalizeOptions {
admin_provider_openai_image_normalize_options(provider_type)
admin_provider_openai_image_normalize_options(provider_type, provider_model)
}
pub(super) fn provider_query_openai_image_requested_count(request_body: &Value) -> Option<u64> {
@@ -35,9 +38,10 @@ pub(super) fn provider_query_openai_image_requested_count(request_body: &Value)
pub(super) fn provider_query_openai_image_normalize_failure_message(
provider_type: &str,
provider_model: Option<&str>,
request_body: &Value,
) -> String {
let capability = provider_query_openai_image_test_capability(provider_type);
let capability = provider_query_openai_image_test_capability(provider_type, provider_model);
if provider_query_openai_image_requested_count(request_body)
.is_some_and(|value| !capability.0.supports_generation_count(value))
{
@@ -337,6 +337,11 @@ fn provider_query_standard_test_resolves_codex_responses_upstream_streaming() {
"codex",
"openai:responses:compact",
));
assert!(!provider_query_resolve_standard_test_upstream_is_stream(
Some(&json!({"upstream_stream_policy": "force_stream"})),
"codex",
"openai:responses:compact",
));
assert!(!provider_query_resolve_standard_test_upstream_is_stream(
None,
"custom",
@@ -997,7 +1002,7 @@ fn provider_query_grok_image_test_allows_multi_generation_count() {
&parts,
&body,
None,
provider_query_openai_image_normalize_options("grok"),
provider_query_openai_image_normalize_options("grok", Some("grok-imagine-image")),
)
.expect("grok image model tests should allow multi-image generation");
let provider_body = crate::ai_serving::build_openai_image_provider_request_body(&normalized);
@@ -1065,12 +1070,48 @@ fn provider_query_non_grok_image_test_keeps_single_generation_boundary() {
&parts,
&body,
None,
provider_query_openai_image_normalize_options("chatgpt_web"),
provider_query_openai_image_normalize_options("chatgpt_web", Some("gpt-image-2")),
)
.is_none()
);
assert_eq!(
provider_query_openai_image_normalize_failure_message("chatgpt_web", &body),
provider_query_openai_image_normalize_failure_message(
"chatgpt_web",
Some("gpt-image-2"),
&body,
),
"Provider request body could not be normalized for openai:image: selected provider supports n=1..1 for generation"
);
}
#[test]
fn provider_query_dall_e_3_image_test_keeps_single_generation_boundary() {
let request = http::Request::builder()
.uri("/v1/images/generations")
.body(())
.expect("request should build");
let (parts, _) = request.into_parts();
let body = json!({
"model": "dall-e-3",
"prompt": "draw",
"n": 2
});
assert!(
crate::ai_serving::normalize_openai_image_request_with_options(
&parts,
&body,
None,
provider_query_openai_image_normalize_options("openai", Some("dall-e-3")),
)
.is_none()
);
assert_eq!(
provider_query_openai_image_normalize_failure_message(
"openai",
Some("dall-e-3"),
&body,
),
"Provider request body could not be normalized for openai:image: selected provider supports n=1..1 for generation"
);
}
@@ -1,5 +1,6 @@
use crate::image_capabilities::{
openai_image_normalize_options_for_provider, openai_image_provider_max_generation_count,
openai_image_normalize_options_for_provider,
openai_image_provider_max_generation_count_for_model,
};
use serde_json::{json, Value};
@@ -24,16 +25,21 @@ impl AdminProviderOpenAiImageTestCapability {
pub(crate) fn admin_provider_openai_image_test_capability(
provider_type: &str,
provider_model: Option<&str>,
) -> AdminProviderOpenAiImageTestCapability {
AdminProviderOpenAiImageTestCapability {
max_generation_count: openai_image_provider_max_generation_count(provider_type),
max_generation_count: openai_image_provider_max_generation_count_for_model(
provider_type,
provider_model,
),
}
}
pub(crate) fn admin_provider_openai_image_normalize_options(
provider_type: &str,
provider_model: Option<&str>,
) -> crate::ai_serving::OpenAiImageNormalizeOptions {
openai_image_normalize_options_for_provider(provider_type)
openai_image_normalize_options_for_provider(provider_type, provider_model)
}
pub(crate) fn admin_provider_model_test_capabilities_payload(
@@ -47,7 +53,7 @@ pub(crate) fn admin_provider_model_test_capabilities_payload(
provider_type.eq_ignore_ascii_case("grok") && model_id == GROK_IMAGE_EDIT_MODEL_ID;
let openai_image = if supports_image_generation {
Some(json!({
"max_generation_count": admin_provider_openai_image_test_capability(provider_type).max_generation_count,
"max_generation_count": admin_provider_openai_image_test_capability(provider_type, Some(model_id)).max_generation_count,
"supports_generation": !is_grok_image_edit,
"supports_edit": is_grok_image_edit,
}))
@@ -105,6 +111,13 @@ mod tests {
assert!(payload["openai:image"].is_null());
}
#[test]
fn dall_e_3_reports_its_model_specific_generation_limit() {
let payload = admin_provider_model_test_capabilities_payload("openai", "dall-e-3", true);
assert_eq!(payload["openai:image"]["max_generation_count"], 1);
}
#[test]
fn grok_image_support_uses_catalog_model_ids_not_global_fallback() {
assert!(admin_provider_model_supports_image_generation(
@@ -2,7 +2,7 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRe
use crate::handlers::admin::provider::write::normalize::{
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
normalize_max_probe_interval_minutes, validate_vertex_api_formats,
normalize_max_probe_interval_minutes, normalize_rate_multipliers, validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -165,7 +165,7 @@ pub(crate) async fn build_admin_create_provider_key_record(
},
encrypted_api_key,
encrypted_auth_config,
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?,
normalize_rate_multipliers(payload.rate_multipliers)?,
None,
normalize_string_list(payload.allowed_models).map(|value| json!(value)),
None,
@@ -2,7 +2,7 @@ use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePa
use crate::handlers::admin::provider::write::normalize::{
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
normalize_max_probe_interval_minutes, validate_vertex_api_formats,
normalize_max_probe_interval_minutes, normalize_rate_multipliers, validate_vertex_api_formats,
};
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::admin::shared::{
@@ -260,8 +260,7 @@ pub(crate) async fn build_admin_update_provider_key_record(
updated.name = trimmed.to_string();
}
if fields.contains("rate_multipliers") {
updated.rate_multipliers =
normalize_api_format_json_object_keys(payload.rate_multipliers, "rate_multipliers")?;
updated.rate_multipliers = normalize_rate_multipliers(payload.rate_multipliers)?;
}
if let Some(internal_priority) = payload.internal_priority {
updated.internal_priority = internal_priority;
@@ -42,6 +42,31 @@ pub(crate) fn normalize_api_format_json_object_keys(
Ok(Some(serde_json::Value::Object(normalized)))
}
pub(crate) fn normalize_rate_multipliers(
value: Option<serde_json::Value>,
) -> Result<Option<serde_json::Value>, String> {
let Some(value) = normalize_json_like_object(value, "rate_multipliers")? else {
return Ok(None);
};
let serde_json::Value::Object(map) = value else {
return Ok(Some(value));
};
let mut normalized = serde_json::Map::new();
for (key, value) in map {
let canonical = crate::ai_serving::normalize_api_format_alias(&key);
let multiplier = value
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.ok_or_else(|| format!("rate_multipliers.{canonical} 必须是大于或等于 0 的有限数值"))?;
normalized.insert(canonical, serde_json::Value::from(multiplier));
}
if normalized.is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::Value::Object(normalized)))
}
}
pub(crate) fn normalize_auth_type_by_format(
value: Option<serde_json::Value>,
field_name: &str,
@@ -212,7 +237,7 @@ mod tests {
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
normalize_api_format_list, normalize_auth_type, normalize_auth_type_by_format,
normalize_chat_pii_redaction_config, normalize_pool_advanced_config,
normalize_provider_type_input, validate_vertex_api_formats,
normalize_provider_type_input, normalize_rate_multipliers, validate_vertex_api_formats,
};
use serde_json::json;
@@ -224,6 +249,21 @@ mod tests {
);
}
#[test]
fn rate_multipliers_require_non_negative_finite_numbers() {
assert_eq!(
normalize_rate_multipliers(Some(json!({" OPENAI:RESPONSES ": 1.25})))
.expect("valid multiplier should normalize"),
Some(json!({"openai:responses": 1.25}))
);
for value in [
json!({"openai:responses": -0.1}),
json!({"openai:responses": "1.0"}),
] {
assert!(normalize_rate_multipliers(Some(value)).is_err());
}
}
#[test]
fn normalize_pool_advanced_rejects_legacy_booleans() {
assert_eq!(
@@ -1,9 +1,8 @@
use crate::ai_serving::normalize_openai_image_quality;
use crate::async_task::CancelVideoTaskError;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::image_capabilities::{
openai_image_gateway_max_generation_count, openai_image_gateway_max_generation_count_for_model,
};
use crate::image_capabilities::openai_image_gateway_max_generation_count;
use crate::{AppState, GatewayError};
use aether_data_contracts::repository::video_tasks::{
StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus,
@@ -26,7 +25,7 @@ const OPENAI_IMAGE_PARTIAL_IMAGES_DETAIL: &str =
const OPENAI_IMAGE_STYLE_DETAIL: &str = "当前 Codex 图片反代暂不支持 style 参数";
const OPENAI_IMAGE_RESPONSE_FORMAT_DETAIL: &str = "response_format 仅支持 url 或 b64_json";
const OPENAI_IMAGE_OUTPUT_FORMAT_DETAIL: &str = "output_format 仅支持 png、jpeg 或 webp";
const OPENAI_IMAGE_QUALITY_DETAIL: &str = "quality 仅支持 low、medium、high、standard 或 hd";
const OPENAI_IMAGE_QUALITY_DETAIL: &str = "quality 仅支持 auto、low、medium、high、standard 或 hd";
const OPENAI_IMAGE_BACKGROUND_DETAIL: &str = "background 仅支持 auto、opaque 或 transparent";
const OPENAI_IMAGE_MODERATION_DETAIL: &str = "moderation 仅支持 auto 或 low";
const OPENAI_IMAGE_INPUT_FIDELITY_DETAIL: &str = "input_fidelity 仅支持 low 或 high";
@@ -303,7 +302,7 @@ fn maybe_build_local_openai_request_validation_response(
if validation
.quality
.as_deref()
.is_some_and(|value| !matches!(value, "low" | "medium" | "high" | "standard" | "hd"))
.is_some_and(|value| normalize_openai_image_quality(value).is_none())
{
return Some(build_ai_public_error_response(
http::StatusCode::BAD_REQUEST,
@@ -366,8 +365,7 @@ fn openai_image_n_detail(max_generation_count: u64) -> String {
}
fn validate_openai_image_n(validation: &OpenAiImageValidationInput) -> Option<String> {
let max_generation_count =
openai_image_gateway_max_generation_count_for_model(validation.model.as_deref());
let max_generation_count = openai_image_gateway_max_generation_count();
validation
.n
.is_some_and(|value| value == 0 || value > max_generation_count)
@@ -1761,7 +1759,7 @@ mod tests {
}
#[test]
fn image_validation_restricts_multi_image_count_to_grok_models() {
fn image_validation_applies_the_global_count_limit_before_model_mapping() {
let openai_body = Bytes::from_static(br#"{"model":"gpt-image-2","prompt":"draw","n":2}"#);
let openai_validation = parse_openai_image_validation_input(
OpenAiImageOperation::Generate,
@@ -1770,10 +1768,7 @@ mod tests {
)
.expect("valid image payload should parse");
assert_eq!(
validate_openai_image_n(&openai_validation).as_deref(),
Some("当前图片模型仅支持 n=1..1")
);
assert!(validate_openai_image_n(&openai_validation).is_none());
let grok_body =
Bytes::from_static(br#"{"model":"grok-imagine-image-lite","prompt":"draw","n":4}"#);
@@ -1785,5 +1780,28 @@ mod tests {
.expect("valid grok image payload should parse");
assert!(validate_openai_image_n(&grok_validation).is_none());
let alias_body =
Bytes::from_static(br#"{"model":"production-image-alias","prompt":"draw","n":10}"#);
let alias_validation = parse_openai_image_validation_input(
OpenAiImageOperation::Generate,
Some("application/json"),
&alias_body,
)
.expect("valid image alias payload should parse");
assert!(validate_openai_image_n(&alias_validation).is_none());
let excessive_body =
Bytes::from_static(br#"{"model":"production-image-alias","prompt":"draw","n":11}"#);
let excessive_validation = parse_openai_image_validation_input(
OpenAiImageOperation::Generate,
Some("application/json"),
&excessive_body,
)
.expect("image payload should parse before count validation");
assert_eq!(
validate_openai_image_n(&excessive_validation).as_deref(),
Some("当前图片反代仅支持 n=1..10")
);
}
}
@@ -89,6 +89,7 @@ pub(super) fn build_models_not_found_response(model_id: &str, api_format: &str)
pub(super) fn build_empty_models_list_response(api_format: &str) -> Response<Body> {
match api_format {
"openai:responses" => Json(json!({ "models": [] })).into_response(),
"claude:messages" => Json(json!({
"data": [],
"has_more": false,
@@ -101,6 +102,10 @@ pub(super) fn build_empty_models_list_response(api_format: &str) -> Response<Bod
}
}
pub(super) fn build_codex_models_list_response(models: Vec<serde_json::Value>) -> Response<Body> {
Json(json!({ "models": models })).into_response()
}
pub(super) fn build_openai_models_list_response(
rows: &[StoredMinimalCandidateSelectionRow],
) -> Response<Body> {
@@ -1,21 +1,24 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Debug;
use std::future::Future;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
use axum::{body::Body, response::Response};
use serde_json::Value;
use tokio::time::timeout;
use tracing::warn;
use super::models_responses::{
build_claude_model_detail_response, build_claude_models_list_response,
build_empty_models_list_response, build_gemini_model_detail_response,
build_gemini_models_list_response, build_models_auth_error_response,
build_models_not_found_response, build_openai_model_detail_response,
build_openai_models_list_response,
build_codex_models_list_response, build_empty_models_list_response,
build_gemini_model_detail_response, build_gemini_models_list_response,
build_models_auth_error_response, build_models_not_found_response,
build_openai_model_detail_response, build_openai_models_list_response,
};
use super::models_shared::{
filter_rows_for_models, models_api_format, models_detail_id, models_query_api_formats,
filter_eligible_model_rows, filter_rows_for_models, models_api_format, models_detail_id,
models_query_api_formats,
};
use super::{query_param_value, AppState, GatewayPublicRequestContext};
@@ -23,6 +26,7 @@ use super::{query_param_value, AppState, GatewayPublicRequestContext};
const MODELS_ROUTE_READ_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const MODELS_ROUTE_READ_TIMEOUT: Duration = Duration::from_millis(50);
const CODEX_MODELS_QUERY_API_FORMATS: &[&str] = &["openai:responses"];
async fn await_models_route_read<T, E, Fut>(operation: &'static str, future: Fut) -> Option<T>
where
@@ -72,7 +76,7 @@ fn build_models_read_fallback_response(
}
}
fn sort_and_dedup_model_rows(
fn sort_model_rows(
mut rows: Vec<StoredMinimalCandidateSelectionRow>,
) -> Vec<StoredMinimalCandidateSelectionRow> {
rows.sort_by(|left, right| {
@@ -85,9 +89,15 @@ fn sort_and_dedup_model_rows(
.then(left.key_id.cmp(&right.key_id))
.then(left.model_id.cmp(&right.model_id))
});
rows
}
fn sort_and_dedup_model_rows(
rows: Vec<StoredMinimalCandidateSelectionRow>,
) -> Vec<StoredMinimalCandidateSelectionRow> {
let mut deduped = Vec::with_capacity(rows.len());
let mut last_model_name: Option<String> = None;
for row in rows {
for row in sort_model_rows(rows) {
if last_model_name.as_deref() == Some(row.global_model_name.as_str()) {
continue;
}
@@ -97,22 +107,158 @@ fn sort_and_dedup_model_rows(
deduped
}
fn is_codex_models_api_format(api_format: &str) -> bool {
crate::ai_serving::normalize_api_format_alias(api_format) == "openai:responses"
}
fn is_codex_provider_row(row: &StoredMinimalCandidateSelectionRow) -> bool {
row.provider_type.trim().eq_ignore_ascii_case("codex")
}
fn codex_model_card_is_complete(card: &serde_json::Map<String, Value>) -> bool {
card.get("slug").and_then(Value::as_str).is_some()
&& card.get("display_name").and_then(Value::as_str).is_some()
&& card
.get("supported_reasoning_levels")
.and_then(Value::as_array)
.is_some()
&& card.get("shell_type").and_then(Value::as_str).is_some()
&& card.get("visibility").and_then(Value::as_str).is_some()
&& card
.get("supported_in_api")
.and_then(Value::as_bool)
.is_some()
&& card.get("priority").and_then(Value::as_i64).is_some()
&& card
.get("base_instructions")
.and_then(Value::as_str)
.is_some()
&& card
.get("supports_reasoning_summaries")
.and_then(Value::as_bool)
.is_some()
&& card
.get("support_verbosity")
.and_then(Value::as_bool)
.is_some()
&& card
.get("truncation_policy")
.and_then(Value::as_object)
.is_some()
&& card
.get("supports_parallel_tool_calls")
.and_then(Value::as_bool)
.is_some()
&& card
.get("experimental_supported_tools")
.and_then(Value::as_array)
.is_some()
}
fn project_codex_model_card(
cached_models: &[Value],
source_model: &str,
global_model: &str,
) -> Option<Value> {
let mut card = cached_models
.iter()
.find(|model| {
model.get("id").and_then(Value::as_str) == Some(source_model)
|| model.get("slug").and_then(Value::as_str) == Some(source_model)
})?
.as_object()?
.clone();
if !codex_model_card_is_complete(&card) {
return None;
}
card.remove("id");
card.remove("api_formats");
card.insert("slug".to_string(), Value::String(global_model.to_string()));
Some(Value::Object(card))
}
async fn load_codex_model_cards(
state: &AppState,
rows: &[StoredMinimalCandidateSelectionRow],
) -> Vec<Value> {
let cache_keys = rows
.iter()
.filter(|row| is_codex_provider_row(row))
.map(|row| format!("upstream_models:{}:{}", row.provider_id, row.key_id))
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let cached_values = await_models_route_read(
"codex_models_cache",
state.runtime_state.kv_get_many(&cache_keys),
)
.await
.unwrap_or_default();
let cached_models_by_key = cache_keys
.into_iter()
.zip(cached_values)
.filter_map(|(key, raw)| {
let models = serde_json::from_str::<Vec<Value>>(raw.as_deref()?).ok()?;
Some((key, models))
})
.collect::<BTreeMap<_, _>>();
let mut seen_global_models = BTreeSet::new();
let mut cards = Vec::new();
for row in rows.iter().filter(|row| is_codex_provider_row(row)) {
if seen_global_models.contains(&row.global_model_name) {
continue;
}
let cache_key = format!("upstream_models:{}:{}", row.provider_id, row.key_id);
let Some(cached_models) = cached_models_by_key.get(&cache_key) else {
continue;
};
let source_model =
aether_scheduler_core::select_provider_model_name(row, "openai:responses");
let Some(card) = project_codex_model_card(
cached_models,
source_model.as_str(),
row.global_model_name.as_str(),
) else {
continue;
};
seen_global_models.insert(row.global_model_name.clone());
cards.push(card);
}
cards
}
async fn list_model_rows_for_client_format(
state: &AppState,
api_format: &str,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
let mut collected = Vec::new();
for query_format in models_query_api_formats(api_format) {
let query_api_formats = if is_codex_models_api_format(api_format) {
CODEX_MODELS_QUERY_API_FORMATS
} else {
models_query_api_formats(api_format)
};
for query_format in query_api_formats {
let rows = await_models_route_read(
"candidate_selection_by_api_format",
state.list_minimal_candidate_selection_rows_for_api_format(query_format),
)
.await?;
let mut filtered = filter_rows_for_models(rows, auth_snapshot, query_format);
let mut filtered = if is_codex_models_api_format(api_format) {
filter_eligible_model_rows(rows, auth_snapshot, query_format)
} else {
filter_rows_for_models(rows, auth_snapshot, query_format)
};
collected.append(&mut filtered);
}
Some(sort_and_dedup_model_rows(collected))
if is_codex_models_api_format(api_format) {
collected.retain(is_codex_provider_row);
Some(sort_model_rows(collected))
} else {
Some(sort_and_dedup_model_rows(collected))
}
}
async fn list_model_rows_for_client_format_and_global_model(
@@ -190,6 +336,10 @@ pub(super) async fn maybe_build_local_models_route_response(
if rows.is_empty() {
return Some(build_empty_models_list_response(api_format));
}
if is_codex_models_api_format(api_format) {
let models = load_codex_model_cards(state, &rows).await;
return Some(build_codex_models_list_response(models));
}
let response = match api_format {
"claude:messages" => {
let before_id = query_param_value(
@@ -194,13 +194,12 @@ fn row_exposes_global_model_for_models(
false
}
pub(super) fn filter_rows_for_models(
pub(super) fn filter_eligible_model_rows(
rows: Vec<StoredMinimalCandidateSelectionRow>,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
api_format: &str,
) -> Vec<StoredMinimalCandidateSelectionRow> {
let mut filtered = rows
.into_iter()
rows.into_iter()
.filter(|row| {
auth_snapshot_allows_provider_for_models(
auth_snapshot,
@@ -211,7 +210,15 @@ pub(super) fn filter_rows_for_models(
})
.filter(|row| auth_snapshot_allows_model_for_models(auth_snapshot, &row.global_model_name))
.filter(|row| row_exposes_global_model_for_models(row, api_format))
.collect::<Vec<_>>();
.collect()
}
pub(super) fn filter_rows_for_models(
rows: Vec<StoredMinimalCandidateSelectionRow>,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
api_format: &str,
) -> Vec<StoredMinimalCandidateSelectionRow> {
let mut filtered = filter_eligible_model_rows(rows, auth_snapshot, api_format);
filtered.sort_by(|left, right| left.global_model_name.cmp(&right.global_model_name));
let mut deduped = Vec::new();
let mut last_model_name: Option<String> = None;
+38 -27
View File
@@ -1,41 +1,51 @@
use crate::ai_serving::OpenAiImageNormalizeOptions;
const DEFAULT_OPENAI_IMAGE_MAX_GENERATION_COUNT: u64 = 1;
const DEFAULT_IMAGE_MAX_GENERATION_COUNT: u64 = 1;
const OPENAI_IMAGE_MAX_GENERATION_COUNT: u64 = 10;
const GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT: u64 = 4;
pub(crate) fn openai_image_gateway_max_generation_count() -> u64 {
GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT
}
pub(crate) fn openai_image_gateway_max_generation_count_for_model(model: Option<&str>) -> u64 {
if model.is_some_and(is_grok_openai_image_model) {
GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT
} else {
DEFAULT_OPENAI_IMAGE_MAX_GENERATION_COUNT
}
OPENAI_IMAGE_MAX_GENERATION_COUNT
}
pub(crate) fn openai_image_provider_max_generation_count(provider_type: &str) -> u64 {
if provider_type.trim().eq_ignore_ascii_case("grok") {
GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT
} else if matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"openai" | "codex"
) {
OPENAI_IMAGE_MAX_GENERATION_COUNT
} else {
DEFAULT_OPENAI_IMAGE_MAX_GENERATION_COUNT
DEFAULT_IMAGE_MAX_GENERATION_COUNT
}
}
pub(crate) fn openai_image_provider_max_generation_count_for_model(
provider_type: &str,
provider_model: Option<&str>,
) -> u64 {
let provider_limit = openai_image_provider_max_generation_count(provider_type);
provider_model.map_or(provider_limit, |model| {
if is_dall_e_3_model(model) {
1
} else {
provider_limit
}
})
}
pub(crate) fn openai_image_normalize_options_for_provider(
provider_type: &str,
provider_model: Option<&str>,
) -> OpenAiImageNormalizeOptions {
OpenAiImageNormalizeOptions::with_max_generation_count(
openai_image_provider_max_generation_count(provider_type),
openai_image_provider_max_generation_count_for_model(provider_type, provider_model),
)
}
fn is_grok_openai_image_model(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
.contains("grok-imagine-image")
fn is_dall_e_3_model(model: &str) -> bool {
model.trim().to_ascii_lowercase().starts_with("dall-e-3")
}
#[cfg(test)]
@@ -43,18 +53,19 @@ mod tests {
use super::*;
#[test]
fn grok_owns_gateway_wide_image_generation_count_ceiling() {
assert_eq!(openai_image_gateway_max_generation_count(), 4);
fn image_count_capabilities_follow_provider_and_model_contracts() {
assert_eq!(openai_image_gateway_max_generation_count(), 10);
assert_eq!(openai_image_provider_max_generation_count("grok"), 4);
assert_eq!(openai_image_provider_max_generation_count("openai"), 10);
assert_eq!(openai_image_provider_max_generation_count("codex"), 10);
assert_eq!(openai_image_provider_max_generation_count("custom"), 1);
assert_eq!(
openai_image_gateway_max_generation_count_for_model(Some("grok-imagine-image-lite")),
4
);
assert_eq!(
openai_image_gateway_max_generation_count_for_model(Some("gpt-image-2")),
openai_image_provider_max_generation_count_for_model("openai", Some("dall-e-3")),
1
);
assert_eq!(openai_image_gateway_max_generation_count_for_model(None), 1);
assert_eq!(openai_image_provider_max_generation_count("grok"), 4);
assert_eq!(openai_image_provider_max_generation_count("openai"), 1);
assert_eq!(
openai_image_normalize_options_for_provider("openai", Some("dall-e-3")),
OpenAiImageNormalizeOptions::with_max_generation_count(1)
);
}
}
+151 -31
View File
@@ -3,13 +3,15 @@ use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
ProviderCatalogUpstreamMetadataNamespaceUpdate, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_model_fetch::{
apply_model_filters, fetch_models_from_transports, json_string_list, merge_upstream_metadata,
model_fetch_interval_minutes, model_fetch_startup_delay_seconds, model_fetch_startup_enabled,
preset_models_for_provider, selected_models_fetch_endpoints,
sync_provider_model_whitelist_associations, ModelFetchAssociationStore, ModelFetchRunSummary,
apply_model_filters, fetch_models_from_transports, json_string_list,
model_catalog_upstream_metadata, model_fetch_interval_minutes,
model_fetch_startup_delay_seconds, model_fetch_startup_enabled, preset_models_for_provider,
selected_models_fetch_endpoints, sync_provider_model_whitelist_associations,
upstream_metadata_namespace_updates, ModelFetchAssociationStore, ModelFetchRunSummary,
};
use serde_json::{json, Value};
use tracing::{debug, info, warn};
@@ -239,8 +241,16 @@ async fn fetch_and_persist_key_models(
json_string_list(target.key.model_include_patterns.as_ref()),
json_string_list(target.key.model_exclude_patterns.as_ref()),
);
persist_key_fetch_success(state, &target.key, now_unix_secs, &filtered_models, None)
.await?;
let upstream_metadata =
model_catalog_upstream_metadata(&target.provider.provider_type, &models);
persist_key_fetch_success(
state,
&target.key,
now_unix_secs,
&filtered_models,
upstream_metadata.as_ref(),
)
.await?;
state
.write_upstream_models_cache(&target.provider.id, &target.key.id, &models)
.await;
@@ -328,7 +338,6 @@ async fn fetch_and_persist_key_models(
json_string_list(target.key.model_include_patterns.as_ref()),
json_string_list(target.key.model_exclude_patterns.as_ref()),
);
persist_key_fetch_success(
state,
&target.key,
@@ -352,11 +361,15 @@ async fn persist_key_fetch_failure(
now_unix_secs: u64,
error: String,
) -> Result<(), GatewayError> {
let mut updated = key.clone();
updated.last_models_fetch_at_unix_secs = Some(now_unix_secs);
updated.last_models_fetch_error = Some(error);
updated.updated_at_unix_secs = Some(now_unix_secs);
state.update_provider_catalog_key(&updated).await?;
state
.update_provider_catalog_key_model_fetch_state(
&key.id,
key.allowed_models.as_ref(),
Some(now_unix_secs),
Some(&error),
Some(now_unix_secs),
)
.await?;
Ok(())
}
@@ -367,22 +380,33 @@ async fn persist_key_fetch_success(
allowed_models: &[String],
upstream_metadata: Option<&Value>,
) -> Result<(), GatewayError> {
let mut updated = key.clone();
updated.allowed_models = if allowed_models.is_empty() {
let allowed_models = if allowed_models.is_empty() {
None
} else {
Some(json!(allowed_models))
};
if let Some(upstream_metadata) = upstream_metadata {
updated.upstream_metadata = Some(merge_upstream_metadata(
updated.upstream_metadata.as_ref(),
upstream_metadata,
));
}
updated.last_models_fetch_at_unix_secs = Some(now_unix_secs);
updated.last_models_fetch_error = None;
updated.updated_at_unix_secs = Some(now_unix_secs);
state.update_provider_catalog_key(&updated).await?;
let upstream_metadata_updates = upstream_metadata
.map(|upstream_metadata| {
upstream_metadata_namespace_updates(key.upstream_metadata.as_ref(), upstream_metadata)
.into_iter()
.map(
|(namespace, value)| ProviderCatalogUpstreamMetadataNamespaceUpdate {
namespace,
value,
},
)
.collect::<Vec<_>>()
})
.unwrap_or_default();
state
.update_provider_catalog_key_model_fetch_success(
&key.id,
allowed_models.as_ref(),
now_unix_secs,
&upstream_metadata_updates,
Some(now_unix_secs),
)
.await?;
Ok(())
}
@@ -402,7 +426,8 @@ mod tests {
StoredAdminProviderModel, UpsertAdminProviderModelRecord,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
ProviderCatalogUpstreamMetadataNamespaceUpdate, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_model_fetch::{
build_models_fetch_execution_plan, ModelFetchAssociationStore, ModelFetchTransportRuntime,
@@ -428,6 +453,7 @@ mod tests {
execution_results: Arc<Mutex<VecDeque<ExecutionResult>>>,
executed_plans: Arc<Mutex<Vec<ExecutionPlan>>>,
cached_models: Arc<Mutex<HashMap<(String, String), Vec<Value>>>>,
upstream_metadata_updates: Arc<Mutex<Vec<(String, String, Value, Option<u64>)>>>,
}
impl TestState {
@@ -446,6 +472,7 @@ mod tests {
execution_results: Arc::new(Mutex::new(VecDeque::from(execution_results))),
executed_plans: Arc::new(Mutex::new(Vec::new())),
cached_models: Arc::new(Mutex::new(HashMap::new())),
upstream_metadata_updates: Arc::new(Mutex::new(Vec::new())),
}
}
@@ -615,15 +642,63 @@ mod tests {
))
}
async fn update_provider_catalog_key(
async fn update_provider_catalog_key_model_fetch_state(
&self,
key: &StoredProviderCatalogKey,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: Option<u64>,
last_models_fetch_error: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError> {
let mut keys = self.keys.lock().expect("keys mutex");
let Some(slot) = keys.iter_mut().find(|item| item.id == key.id) else {
let Some(key) = keys.iter_mut().find(|item| item.id == key_id) else {
return Err(GatewayError::Internal("key not found".to_string()));
};
*slot = key.clone();
key.allowed_models = allowed_models.cloned();
key.last_models_fetch_at_unix_secs = last_models_fetch_at_unix_secs;
key.last_models_fetch_error = last_models_fetch_error.map(str::to_string);
key.updated_at_unix_secs = updated_at_unix_secs;
Ok(())
}
async fn update_provider_catalog_key_model_fetch_success(
&self,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: u64,
upstream_metadata_updates: &[ProviderCatalogUpstreamMetadataNamespaceUpdate],
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError> {
let mut keys = self.keys.lock().expect("keys mutex");
let Some(key) = keys.iter_mut().find(|key| key.id == key_id) else {
return Err(GatewayError::Internal("key not found".to_string()));
};
key.allowed_models = allowed_models.cloned();
key.last_models_fetch_at_unix_secs = Some(last_models_fetch_at_unix_secs);
key.last_models_fetch_error = None;
if !upstream_metadata_updates.is_empty() {
let metadata = key
.upstream_metadata
.get_or_insert_with(|| json!({}))
.as_object_mut()
.expect("upstream metadata object");
for update in upstream_metadata_updates {
metadata.insert(update.namespace.clone(), update.value.clone());
}
}
key.updated_at_unix_secs = updated_at_unix_secs;
drop(keys);
self.upstream_metadata_updates
.lock()
.expect("metadata updates mutex")
.extend(upstream_metadata_updates.iter().map(|update| {
(
key_id.to_string(),
update.namespace.clone(),
update.value.clone(),
updated_at_unix_secs,
)
}));
Ok(())
}
@@ -815,12 +890,19 @@ mod tests {
#[tokio::test]
async fn model_fetch_uses_preset_models_without_endpoint() {
let provider = sample_provider("provider-codex", "codex");
let key = sample_key(
let mut key = sample_key(
"key-codex",
"provider-codex",
"api_key",
&["openai:responses"],
);
key.upstream_metadata = Some(json!({
"codex": {
"quota_by_model": {
"gpt-5.6-sol": {"remaining_fraction": 0.75}
}
}
}));
let state = TestState::new(vec![provider], vec![], vec![key], HashMap::new(), vec![]);
let summary = perform_model_fetch_once_with_state(&state)
@@ -832,9 +914,47 @@ mod tests {
let updated = state.key("key-codex");
let allowed_models = updated
.allowed_models
.as_ref()
.and_then(|value| value.as_array().cloned())
.expect("allowed_models should be set");
assert!(allowed_models.iter().any(|model| model == "gpt-5.4"));
let upstream_metadata = updated
.upstream_metadata
.as_ref()
.expect("Codex model catalog should be persisted");
assert_eq!(
upstream_metadata["codex"]["quota_by_model"]["gpt-5.6-sol"]["remaining_fraction"],
0.75
);
assert_eq!(
upstream_metadata["codex_models"]["cards"]["gpt-5.6-sol"]["multi_agent_version"],
"v2"
);
let capabilities = crate::ai_serving::resolve_codex_responses_model_capabilities(
"gpt-5.6-sol",
"gpt-5.6-sol",
Some(upstream_metadata),
);
assert!(capabilities.use_responses_lite);
assert_eq!(
capabilities.default_reasoning_effort.as_deref(),
Some("low")
);
assert!(capabilities
.supported_reasoning_efforts
.iter()
.any(|effort| effort == "ultra"));
let metadata_updates = state
.upstream_metadata_updates
.lock()
.expect("metadata updates mutex");
assert_eq!(metadata_updates.len(), 1);
assert_eq!(metadata_updates[0].0, "key-codex");
assert_eq!(metadata_updates[0].1, "codex_models");
assert_eq!(
metadata_updates[0].2["cards"]["gpt-5.6-sol"]["multi_agent_version"],
"v2"
);
assert!(state
.cached_models
.lock()
@@ -4,7 +4,8 @@ use aether_data_contracts::repository::global_models::{
StoredAdminProviderModel, UpsertAdminProviderModelRecord,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
ProviderCatalogUpstreamMetadataNamespaceUpdate, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_model_fetch::{ModelFetchAssociationStore, ModelFetchTransportRuntime};
use async_trait::async_trait;
@@ -42,9 +43,22 @@ pub(crate) trait ModelFetchRuntimeState:
plan: &ExecutionPlan,
) -> Result<ExecutionResult, GatewayError>;
async fn update_provider_catalog_key(
async fn update_provider_catalog_key_model_fetch_state(
&self,
key: &StoredProviderCatalogKey,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: Option<u64>,
last_models_fetch_error: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError>;
async fn update_provider_catalog_key_model_fetch_success(
&self,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: u64,
upstream_metadata_updates: &[ProviderCatalogUpstreamMetadataNamespaceUpdate],
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError>;
async fn write_upstream_models_cache(
@@ -310,6 +310,89 @@ async fn gateway_model_fetch_updates_key_and_syncs_provider_model_whitelist_asso
execution_runtime_handle.abort();
}
#[tokio::test]
async fn codex_preset_model_fetch_associates_the_api_supported_review_model() {
let provider = StoredProviderCatalogProvider::new(
"provider-codex".to_string(),
"codex".to_string(),
Some("https://chatgpt.com".to_string()),
"codex".to_string(),
)
.expect("provider should build")
.with_transport_fields(true, false, true, None, None, None, None, None, None);
let mut key = sample_key("provider-codex", "key-codex");
key.locked_models = None;
key.model_include_patterns = None;
key.model_exclude_patterns = None;
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
vec![key],
));
let global_model_repository = Arc::new(
InMemoryGlobalModelReadRepository::seed(Vec::new()).with_admin_global_models(vec![
sample_global_model(
"global-model-codex-auto-review",
"codex-auto-review",
&["codex-auto-review"],
),
]),
);
let data_state = crate::data::GatewayDataState::disabled()
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository))
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state);
let summary = perform_model_fetch_once(&state)
.await
.expect("Codex preset model fetch should succeed");
assert_eq!(
summary,
ModelFetchRunSummary {
attempted: 1,
succeeded: 1,
failed: 0,
skipped: 0,
}
);
let updated_key = provider_catalog_repository
.list_keys_by_ids(&["key-codex".to_string()])
.await
.expect("keys should load")
.into_iter()
.next()
.expect("updated key should exist");
assert!(updated_key
.allowed_models
.as_ref()
.and_then(serde_json::Value::as_array)
.is_some_and(|models| models.iter().any(|model| model == "codex-auto-review")));
assert!(updated_key
.upstream_metadata
.as_ref()
.is_some_and(|metadata| {
metadata["codex_models"]["cards"]["codex-auto-review"]["supported_in_api"] == true
}));
let provider_models = global_model_repository
.list_admin_provider_models(&AdminProviderModelListQuery {
provider_id: "provider-codex".to_string(),
is_active: None,
offset: 0,
limit: 10_000,
})
.await
.expect("provider models should load");
assert!(provider_models.iter().any(|model| {
model.provider_model_name == "codex-auto-review"
&& model.global_model_id == "global-model-codex-auto-review"
}));
}
#[tokio::test]
async fn gateway_model_fetch_updates_key_and_syncs_provider_model_whitelist_associations_without_execution_runtime_override(
) {
+1
View File
@@ -508,6 +508,7 @@ mod tests {
auth_context: Some(auth_context),
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -133,7 +133,7 @@ pub(crate) async fn resolve_request_candidate_required_capabilities(
api_key_id: &str,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Value>,
enable_model_directives: bool,
model_directive_base_model: Option<&str>,
) -> Option<Value> {
let mut merged = serde_json::Map::new();
@@ -146,7 +146,7 @@ pub(crate) async fn resolve_request_candidate_required_capabilities(
select_requested_model_capabilities(
settings.as_ref(),
requested_model,
enable_model_directives,
model_directive_base_model,
),
),
Err(error) => {
@@ -199,7 +199,7 @@ fn merge_capability_object(target: &mut serde_json::Map<String, Value>, source:
fn select_requested_model_capabilities<'a>(
settings: Option<&'a Value>,
requested_model: Option<&str>,
enable_model_directives: bool,
model_directive_base_model: Option<&str>,
) -> Option<&'a Value> {
let requested_model = requested_model
.map(str::trim)
@@ -207,10 +207,9 @@ fn select_requested_model_capabilities<'a>(
let settings = settings?.as_object()?;
find_model_capabilities(settings, requested_model).or_else(|| {
enable_model_directives
.then(|| crate::ai_serving::model_directive_base_model(requested_model))
.flatten()
.as_deref()
model_directive_base_model
.map(str::trim)
.filter(|base_model| !base_model.is_empty() && *base_model != requested_model)
.and_then(|base_model| find_model_capabilities(settings, base_model))
})
}
@@ -924,7 +923,7 @@ mod tests {
use super::{
ensure_execution_request_candidate_slot, persist_available_local_candidate,
record_report_request_candidate_status, resolve_request_candidate_required_capabilities,
SchedulerRequestCandidateStatusUpdate,
select_requested_model_capabilities, SchedulerRequestCandidateStatusUpdate,
};
use crate::data::GatewayDataState;
use crate::AppState;
@@ -997,6 +996,7 @@ mod tests {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5".to_string(),
selected_provider_model_name: "gpt-5".to_string(),
supports_streaming: true,
mapping_matched_model: None,
}
}
@@ -1230,7 +1230,7 @@ mod tests {
"api-key-1",
Some("gpt-5"),
Some(&explicit_required_capabilities),
false,
None,
)
.await
.expect("required capabilities should resolve");
@@ -1240,6 +1240,48 @@ mod tests {
assert_eq!(required_capabilities["gemini_files"], json!(true));
}
#[test]
fn requested_model_capabilities_use_the_policy_resolved_base_model() {
let base_only = json!({
"deployment-alias": {
"context_1m": true
}
});
assert_eq!(
select_requested_model_capabilities(
Some(&base_only),
Some("deployment-alias-VendorFuture"),
Some("deployment-alias"),
),
Some(&base_only["deployment-alias"])
);
assert_eq!(
select_requested_model_capabilities(
Some(&base_only),
Some("deployment-alias-VendorFuture"),
None,
),
None
);
let exact_and_base = json!({
"deployment-alias-VendorFuture": {
"cache_1h": true
},
"deployment-alias": {
"context_1m": true
}
});
assert_eq!(
select_requested_model_capabilities(
Some(&exact_and_base),
Some("deployment-alias-VendorFuture"),
Some("deployment-alias"),
),
Some(&exact_and_base["deployment-alias-VendorFuture"])
);
}
#[tokio::test]
async fn persists_request_required_capabilities_instead_of_provider_key_capabilities() {
let repository = Arc::new(InMemoryRequestCandidateRepository::default());
@@ -138,6 +138,7 @@ fn scheduler_candidate_is_serializable() {
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-4.1".to_string(),
selected_provider_model_name: "gpt-4.1-canary".to_string(),
supports_streaming: true,
mapping_matched_model: Some("gpt-4.1-canary".to_string()),
};
+74
View File
@@ -721,6 +721,79 @@ impl AppState {
Ok(updated)
}
pub(crate) async fn upsert_provider_catalog_key_upstream_metadata_namespace(
&self,
key_id: &str,
namespace: &str,
value: &serde_json::Value,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
let updated = self
.data
.upsert_provider_catalog_key_upstream_metadata_namespace(
key_id,
namespace,
value,
updated_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_routing_caches();
}
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_model_fetch_state(
&self,
key_id: &str,
allowed_models: Option<&serde_json::Value>,
last_models_fetch_at_unix_secs: Option<u64>,
last_models_fetch_error: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_model_fetch_state(
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
last_models_fetch_error,
updated_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_routing_caches();
}
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_model_fetch_success(
&self,
key_id: &str,
allowed_models: Option<&serde_json::Value>,
last_models_fetch_at_unix_secs: u64,
upstream_metadata_updates: &[provider_catalog::ProviderCatalogUpstreamMetadataNamespaceUpdate],
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_model_fetch_success(
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
upstream_metadata_updates,
updated_at_unix_secs,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_routing_caches();
}
Ok(updated)
}
pub(crate) async fn delete_provider_catalog_key(
&self,
key_id: &str,
@@ -1341,6 +1414,7 @@ mod tests {
"fixed_order",
true,
None,
"",
);
state.candidate_page_cache.insert(
cache_key.clone(),
+37 -4
View File
@@ -9,7 +9,8 @@ use aether_data_contracts::repository::global_models::{
StoredAdminProviderModel, UpsertAdminProviderModelRecord,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
ProviderCatalogUpstreamMetadataNamespaceUpdate, StoredProviderCatalogEndpoint,
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
use aether_model_fetch::{
@@ -319,11 +320,43 @@ impl ModelFetchRuntimeState for AppState {
execution_runtime::execute_execution_runtime_sync_plan(self, None, plan).await
}
async fn update_provider_catalog_key(
async fn update_provider_catalog_key_model_fetch_state(
&self,
key: &StoredProviderCatalogKey,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: Option<u64>,
last_models_fetch_error: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError> {
AppState::update_provider_catalog_key(self, key).await?;
AppState::update_provider_catalog_key_model_fetch_state(
self,
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
last_models_fetch_error,
updated_at_unix_secs,
)
.await?;
Ok(())
}
async fn update_provider_catalog_key_model_fetch_success(
&self,
key_id: &str,
allowed_models: Option<&Value>,
last_models_fetch_at_unix_secs: u64,
upstream_metadata_updates: &[ProviderCatalogUpstreamMetadataNamespaceUpdate],
updated_at_unix_secs: Option<u64>,
) -> Result<(), GatewayError> {
AppState::update_provider_catalog_key_model_fetch_success(
self,
key_id,
allowed_models,
last_models_fetch_at_unix_secs,
upstream_metadata_updates,
updated_at_unix_secs,
)
.await?;
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -463,8 +463,8 @@ async fn gateway_executes_openai_chat_stream_via_local_openai_responses_cross_fo
accept: String,
authorization: String,
x_client_request_id: String,
session_id: String,
conversation_id: String,
codex_session_id: String,
codex_thread_id: String,
instructions: String,
user_text: String,
prompt_cache_key: String,
@@ -742,15 +742,15 @@ async fn gateway_executes_openai_chat_stream_via_local_openai_responses_cross_fo
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
session_id: payload
codex_session_id: payload
.get("headers")
.and_then(|value| value.get("session_id"))
.and_then(|value| value.get("session-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
conversation_id: payload
codex_thread_id: payload
.get("headers")
.and_then(|value| value.get("conversation_id"))
.and_then(|value| value.get("thread-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
@@ -889,19 +889,16 @@ async fn gateway_executes_openai_chat_stream_via_local_openai_responses_cross_fo
);
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"trace-openai-chat-cli-local-123"
seen_execution_runtime_request.codex_thread_id
);
assert_eq!(
seen_execution_runtime_request.prompt_cache_key,
"bc749eb7-a9e2-5793-8d14-abd659c700b0"
seen_execution_runtime_request.codex_session_id,
seen_execution_runtime_request.codex_thread_id
);
assert_eq!(
seen_execution_runtime_request.session_id,
"d1e9b802644e1f52"
);
assert_eq!(
seen_execution_runtime_request.conversation_id,
"d1e9b802644e1f52"
assert!(seen_execution_runtime_request.prompt_cache_key.is_empty());
assert_ne!(
seen_execution_runtime_request.codex_thread_id,
seen_execution_runtime_request.trace_id
);
assert_eq!(
seen_execution_runtime_request.instructions,
@@ -2,7 +2,6 @@ use super::{
any, build_router_with_state, build_state_with_execution_runtime_override, json, start_server,
to_bytes, Arc, Body, Json, Mutex, Request, Router, StatusCode, TRACE_ID_HEADER,
};
use crate::ai_serving::CODEX_OPENAI_IMAGE_INTERNAL_MODEL;
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
@@ -55,13 +54,9 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
struct SeenExecutionRuntimeStreamRequest {
trace_id: String,
url: String,
model: String,
authorization: String,
x_client_request_id: String,
tool_type: String,
tool_action: String,
tool_partial_images: Option<u64>,
request_stream: bool,
headers: serde_json::Value,
body: serde_json::Value,
plan_stream: bool,
}
@@ -180,7 +175,7 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
None,
Some(2),
None,
Some(serde_json::json!({"upstream_stream_policy":"force_stream"})),
None,
None,
None,
)
@@ -272,65 +267,26 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_request_id: payload
.get("headers")
.and_then(|value| value.get("x-client-request-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_type: payload
headers: payload.get("headers").cloned().unwrap_or_default(),
body: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_action: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("action"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_partial_images: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("partial_images"))
.and_then(|value| value.as_u64()),
request_stream: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("stream"))
.and_then(|value| value.as_bool())
.unwrap_or(false),
.cloned()
.unwrap_or_default(),
plan_stream: payload
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false),
});
let frames = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.output_item.done\\ndata: {\\\"type\\\":\\\"response.output_item.done\\\",\\\"output_index\\\":0,\\\"item\\\":{\\\"id\\\":\\\"ig_123\\\",\\\"type\\\":\\\"image_generation_call\\\",\\\"result\\\":\\\"aGVsbG8=\\\"}}\\n\\n\"}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.completed\\ndata: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"tool_usage\\\":{\\\"image_gen\\\":{\\\"input_tokens\\\":11,\\\"output_tokens\\\":22,\\\"total_tokens\\\":33}}}}\\n\\n\"}}\n",
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"application/json\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"{\\\"created\\\":1776991097,\\\"data\\\":[{\\\"b64_json\\\":\\\"aGVsbG8=\\\",\\\"revised_prompt\\\":\\\"水墨视觉海报\\\"}],\\\"usage\\\":{\\\"input_tokens\\\":11,\\\"output_tokens\\\":22,\\\"total_tokens\\\":33}}\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":41}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
@@ -397,26 +353,28 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
)
.header(TRACE_ID_HEADER, "trace-codex-image-stream-local-123")
.body(
"{\"model\":\"gpt-image-2\",\"prompt\":\"生成一张中国历史视觉海报\",\"stream\":true,\"partial_images\":1}",
"{\"model\":\"gpt-image-2\",\"prompt\":\"生成一张水墨视觉海报\",\"background\":\"auto\",\"quality\":\"auto\",\"size\":\"auto\",\"stream\":true,\"response_format\":\"b64_json\"}",
)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let response_status = response.status();
let response_content_type = response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let response_text = response.text().await.expect("body should read");
assert!(response_text.contains("event: image_generation.partial_image"));
assert!(response_text.contains("\"type\":\"image_generation.partial_image\""));
assert!(response_text.contains("\"b64_json\":\"aGVsbG8=\""));
assert_eq!(response_status, StatusCode::OK, "{response_text}");
assert_eq!(
response_content_type.as_deref(),
Some("text/event-stream"),
"{response_text}"
);
assert!(response_text.contains("event: image_generation.completed"));
assert!(response_text.contains("\"type\":\"image_generation.completed\""));
assert!(response_text.contains("\"b64_json\":\"aGVsbG8=\""));
assert!(response_text.contains("\"total_tokens\":33"));
assert!(!response_text.contains("response.completed"));
@@ -444,25 +402,34 @@ async fn gateway_executes_codex_image_stream_via_local_decision_gate_after_oauth
);
assert_eq!(
seen_execution_runtime_request.url,
"https://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(
seen_execution_runtime_request.model,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL
"https://chatgpt.com/backend-api/codex/images/generations"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer refreshed-codex-image-stream-access-token"
);
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"trace-codex-image-stream-local-123"
seen_execution_runtime_request.body,
json!({
"prompt": "生成一张水墨视觉海报",
"background": "auto",
"model": "gpt-image-2",
"quality": "auto",
"size": "auto"
})
);
assert_eq!(seen_execution_runtime_request.tool_type, "image_generation");
assert_eq!(seen_execution_runtime_request.tool_action, "generate");
assert_eq!(seen_execution_runtime_request.tool_partial_images, Some(1));
assert!(seen_execution_runtime_request.request_stream);
assert!(seen_execution_runtime_request.plan_stream);
assert_eq!(
seen_execution_runtime_request.headers["user-agent"],
"codex_cli_rs/0.144.1"
);
assert_eq!(
seen_execution_runtime_request.headers["originator"],
"codex_cli_rs"
);
for header in ["x-client-request-id", "session-id", "thread-id"] {
assert!(seen_execution_runtime_request.headers.get(header).is_none());
}
assert!(!seen_execution_runtime_request.plan_stream);
gateway_handle.abort();
execution_runtime_handle.abort();
@@ -594,7 +561,7 @@ async fn gateway_bridges_codex_image_sync_json_to_streaming_image_sse_impl() {
None,
Some(2),
None,
Some(serde_json::json!({"upstream_stream_policy":"force_stream"})),
None,
None,
None,
)
@@ -772,8 +739,8 @@ async fn gateway_bridges_codex_image_sync_json_to_streaming_image_sse_impl() {
seen_execution_runtime_request.trace_id,
"trace-codex-image-stream-json-123"
);
assert!(seen_execution_runtime_request.request_stream);
assert!(seen_execution_runtime_request.plan_stream);
assert!(!seen_execution_runtime_request.request_stream);
assert!(!seen_execution_runtime_request.plan_stream);
gateway_handle.abort();
execution_runtime_handle.abort();
@@ -1152,12 +1119,14 @@ async fn gateway_routes_openai_responses_stream_image_intent_to_openai_image_pla
seen_plan.url,
"https://images.example.com/v1/images/generations"
);
assert!(seen_plan.plan_stream);
assert!(!seen_plan.plan_stream);
assert_eq!(seen_plan.auth_header, "Bearer sk-upstream-image-bridge");
assert_eq!(seen_plan.body_json["stream"], true);
assert_eq!(seen_plan.body_json["input"], "Draw a mountain observatory");
assert_eq!(seen_plan.body_json["tools"][0]["type"], "image_generation");
assert_eq!(seen_plan.body_json["tools"][0]["size"], "1024x1024");
assert_eq!(seen_plan.body_json["model"], "gpt-image-2");
assert_eq!(seen_plan.body_json["prompt"], "Draw a mountain observatory");
assert_eq!(seen_plan.body_json["size"], "1024x1024");
assert!(seen_plan.body_json.get("stream").is_none());
assert!(seen_plan.body_json.get("input").is_none());
assert!(seen_plan.body_json.get("tools").is_none());
gateway_handle.abort();
execution_runtime_handle.abort();
@@ -2,7 +2,7 @@ use super::{
any, build_router_with_state, build_state_with_execution_runtime_override, json,
run_stream_cli_test, start_server, to_bytes, Arc, Body, Bytes, HeaderName, HeaderValue,
Infallible, Json, Mutex, Request, Response, Router, StatusCode,
EXECUTION_PATH_EXECUTION_RUNTIME_STREAM, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
EXECUTION_PATH_EXECUTION_RUNTIME_SYNC, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
};
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::auth::{
@@ -23,33 +23,37 @@ use aether_data_contracts::repository::provider_catalog::{
use sha2::{Digest, Sha256};
#[test]
fn gateway_executes_openai_responses_compact_stream_via_local_decision_gate_with_local_stream_decision(
) {
fn gateway_executes_openai_responses_compact_as_unary_request() {
run_stream_cli_test(
"gateway_executes_openai_responses_compact_stream_via_local_decision_gate_with_local_stream_decision",
gateway_executes_openai_responses_compact_stream_via_local_decision_gate_with_local_stream_decision_impl,
"gateway_executes_openai_responses_compact_as_unary_request",
gateway_executes_openai_responses_compact_as_unary_request_impl,
);
}
async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gate_with_local_stream_decision_impl(
) {
async fn gateway_executes_openai_responses_compact_as_unary_request_impl() {
#[derive(Debug, Clone)]
struct SeenExecutionRuntimeStreamRequest {
trace_id: String,
url: String,
model: String,
content_encoding: String,
stream: bool,
accept: String,
turn_state: String,
authorization: String,
chatgpt_account_id: String,
fedramp: String,
responses_lite: String,
session_id: String,
thread_id: String,
x_client_request_id_present: bool,
endpoint_tag: String,
conditional_header: String,
renamed_header: String,
dropped_header_present: bool,
metadata_mode: String,
metadata_source: String,
metadata_origin: String,
instructions: String,
store_present: bool,
body: serde_json::Value,
proxy_node_id: String,
transport_profile_id: String,
}
@@ -71,7 +75,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:responses:compact"])),
Some(serde_json::json!(["gpt-5"])),
Some(serde_json::json!(["gpt-5.6-sol"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
@@ -82,7 +86,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
Some(4_102_444_800_i64),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:responses:compact"])),
Some(serde_json::json!(["gpt-5"])),
Some(serde_json::json!(["gpt-5.6-sol"])),
)
.expect("auth snapshot should build")
}
@@ -91,7 +95,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
StoredMinimalCandidateSelectionRow {
provider_id: "provider-openai-compact-local-1".to_string(),
provider_name: "openai".to_string(),
provider_type: "custom".to_string(),
provider_type: "codex".to_string(),
provider_priority: 10,
provider_is_active: true,
endpoint_id: "endpoint-openai-compact-local-1".to_string(),
@@ -101,7 +105,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
endpoint_is_active: true,
key_id: "key-openai-compact-local-1".to_string(),
key_name: "prod".to_string(),
key_auth_type: "bearer".to_string(),
key_auth_type: "oauth".to_string(),
key_is_active: true,
key_api_formats: Some(vec!["openai:responses:compact".to_string()]),
key_allowed_models: None,
@@ -110,12 +114,12 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
key_global_priority_by_format: Some(serde_json::json!({"openai:responses:compact": 1})),
model_id: "model-openai-compact-local-1".to_string(),
global_model_id: "global-model-openai-compact-local-1".to_string(),
global_model_name: "gpt-5".to_string(),
global_model_name: "gpt-5.6-sol".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5-upstream".to_string(),
model_provider_model_name: "deployment-production".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-5-upstream".to_string(),
name: "deployment-production".to_string(),
priority: 1,
api_formats: Some(vec!["openai:responses:compact".to_string()]),
endpoint_ids: None,
@@ -131,7 +135,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
"provider-openai-compact-local-1".to_string(),
"openai".to_string(),
Some("https://example.com".to_string()),
"custom".to_string(),
"codex".to_string(),
)
.expect("provider should build")
.with_transport_fields(
@@ -161,15 +165,12 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
"https://api.openai.example".to_string(),
Some(serde_json::json!([
{"action":"set","key":"x-endpoint-tag","value":"openai-compact-local"},
{"action":"set","key":"x-conditional-tag","value":"header-condition-hit","condition":{"path":"instructions","op":"exists","source":"current"}},
{"action":"set","key":"x-conditional-tag","value":"header-condition-hit","condition":{"path":"reasoning","op":"exists","source":"current"}},
{"action":"rename","from":"x-client-rename","to":"x-upstream-rename"},
{"action":"drop","key":"x-drop-me"}
])),
Some(serde_json::json!([
{"action":"set","path":"instructions","value":"You are GPT-5.","condition":{"path":"instructions","op":"not_exists","source":"current"}},
{"action":"set","path":"metadata.mode","value":"safe","condition":{"path":"metadata.mode","op":"not_exists","source":"current"}},
{"action":"rename","from":"metadata.client","to":"metadata.source"},
{"action":"set","path":"metadata.origin","value":"from-original","condition":{"path":"metadata.client","op":"exists","source":"original"}},
{"action":"set","path":"instructions","value":"Use the configured tools.","condition":{"path":"instructions","op":"not_exists","source":"current"}},
{"action":"drop","path":"store"}
])),
Some(2),
@@ -186,7 +187,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
"key-openai-compact-local-1".to_string(),
"provider-openai-compact-local-1".to_string(),
"prod".to_string(),
"bearer".to_string(),
"oauth".to_string(),
None,
true,
)
@@ -198,7 +199,13 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
"sk-upstream-openai-compact",
)
.expect("api key should encrypt"),
None,
Some(
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
r#"{"account_id":"acc-compact-local-123","is_fedramp":true}"#,
)
.expect("auth config should encrypt"),
),
None,
Some(serde_json::json!({"openai:responses:compact": 1})),
None,
@@ -242,7 +249,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
}),
)
.route(
"/api/internal/gateway/decision-stream",
"/api/internal/gateway/decision-sync",
any(move |_request: Request| {
let decision_hits_inner = Arc::clone(&decision_hits_clone);
async move {
@@ -252,7 +259,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
}),
)
.route(
"/api/internal/gateway/plan-stream",
"/api/internal/gateway/plan-sync",
any(move |_request: Request| {
let plan_hits_inner = Arc::clone(&plan_hits_clone);
async move {
@@ -262,7 +269,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
}),
)
.route(
"/api/internal/gateway/report-stream",
"/api/internal/gateway/report-sync",
any(move |request: Request| {
let seen_report_inner = Arc::clone(&seen_report_clone);
async move {
@@ -299,135 +306,166 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
);
let execution_runtime = Router::new().route(
"/v1/execute/stream",
"/v1/execute/sync",
any(move |request: Request| {
let seen_execution_runtime_inner = Arc::clone(&seen_execution_runtime_clone);
async move {
let (parts, body) = request.into_parts();
let raw_body = to_bytes(body, usize::MAX).await.expect("body should read");
let payload: serde_json::Value =
serde_json::from_slice(&raw_body).expect("execution runtime payload should parse");
*seen_execution_runtime_inner.lock().expect("mutex should lock") =
Some(SeenExecutionRuntimeStreamRequest {
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
url: payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
stream: payload
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false),
accept: payload
.get("headers")
.and_then(|value| value.get("accept"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
endpoint_tag: payload
.get("headers")
.and_then(|value| value.get("x-endpoint-tag"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
conditional_header: payload
.get("headers")
.and_then(|value| value.get("x-conditional-tag"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
renamed_header: payload
.get("headers")
.and_then(|value| value.get("x-upstream-rename"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
dropped_header_present: payload
.get("headers")
.and_then(|value| value.get("x-drop-me"))
.is_some(),
metadata_mode: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("metadata"))
.and_then(|value| value.get("mode"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
metadata_source: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("metadata"))
.and_then(|value| value.get("source"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
metadata_origin: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("metadata"))
.and_then(|value| value.get("origin"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
instructions: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("instructions"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
store_present: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("store"))
.is_some(),
proxy_node_id: payload
.get("proxy")
.and_then(|value| value.get("node_id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
transport_profile_id: payload
.get("transport_profile").and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
});
let stream = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.completed\\ndata: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp-compact-local-123\\\",\\\"object\\\":\\\"response\\\",\\\"model\\\":\\\"gpt-5-upstream\\\",\\\"output\\\":[]}}\\n\\n\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":41,\"ttfb_ms\":11}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
let mut response = Response::builder()
.status(StatusCode::OK)
.body(Body::from(stream))
.expect("response should build");
response.headers_mut().insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/x-ndjson"),
);
response
let payload: serde_json::Value = serde_json::from_slice(&raw_body)
.expect("execution runtime payload should parse");
*seen_execution_runtime_inner
.lock()
.expect("mutex should lock") = Some(SeenExecutionRuntimeStreamRequest {
trace_id: parts
.headers
.get(TRACE_ID_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string(),
url: payload
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
content_encoding: payload
.get("content_encoding")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
stream: payload
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false),
accept: payload
.get("headers")
.and_then(|value| value.get("accept"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
turn_state: payload
.get("headers")
.and_then(|value| value.get("x-codex-turn-state"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
chatgpt_account_id: payload
.get("headers")
.and_then(|value| value.get("chatgpt-account-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
fedramp: payload
.get("headers")
.and_then(|value| value.get("x-openai-fedramp"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
responses_lite: payload
.get("headers")
.and_then(|value| value.get("x-openai-internal-codex-responses-lite"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
session_id: payload
.get("headers")
.and_then(|value| value.get("session-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
thread_id: payload
.get("headers")
.and_then(|value| value.get("thread-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_request_id_present: payload
.get("headers")
.and_then(|value| value.get("x-client-request-id"))
.is_some(),
endpoint_tag: payload
.get("headers")
.and_then(|value| value.get("x-endpoint-tag"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
conditional_header: payload
.get("headers")
.and_then(|value| value.get("x-conditional-tag"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
renamed_header: payload
.get("headers")
.and_then(|value| value.get("x-upstream-rename"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
dropped_header_present: payload
.get("headers")
.and_then(|value| value.get("x-drop-me"))
.is_some(),
instructions: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("instructions"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
store_present: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("store"))
.is_some(),
body: payload
.get("body")
.and_then(|value| value.get("json_body"))
.cloned()
.unwrap_or(serde_json::Value::Null),
proxy_node_id: payload
.get("proxy")
.and_then(|value| value.get("node_id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
transport_profile_id: payload
.get("transport_profile")
.and_then(|value| value.get("profile_id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
});
Json(json!({
"request_id": "trace-openai-compact-local-123",
"status_code": 200,
"headers": {
"content-type": "application/json",
"x-codex-turn-state": "turn-state-compact-123"
},
"body": {
"json_body": {
"output": [{
"type": "compaction",
"id": "cmp-compact-local-123",
"encrypted_content": "encrypted-compact-history"
}]
}
},
"telemetry": {"elapsed_ms": 41, "ttfb_ms": 11}
}))
}
}),
);
@@ -474,23 +512,40 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
)
.header("x-client-rename", "rename-openai-compact")
.header("x-drop-me", "drop-openai-compact")
.header("x-codex-turn-state", "turn-state-inbound-123")
.header("session-id", "session-compact-local-123")
.header("thread-id", "thread-compact-local-123")
.header(TRACE_ID_HEADER, "trace-openai-compact-local-123")
.body("{\"model\":\"gpt-5\",\"input\":\"hello\",\"stream\":true,\"metadata\":{\"client\":\"desktop-openai-compact\"},\"store\":false}")
.body(r#"{"model":"gpt-5.6-sol","input":"hello","client_metadata":{"origin":"codex"},"include":["reasoning.encrypted_content"],"store":false,"stream":true,"stream_options":{"reasoning_summary_delivery":"sequential_cutoff"},"tool_choice":"auto","parallel_tool_calls":true,"reasoning":{"effort":"high"},"text":{"verbosity":"medium"},"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"prompt_cache_key":"session:compact-e2e"}"#)
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
if response.status() != StatusCode::OK {
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await.expect("error body should read");
panic!("Compact request failed: status={status}, headers={headers:?}, body={body}");
}
assert_eq!(
response
.headers()
.get("x-codex-turn-state")
.and_then(|value| value.to_str().ok()),
Some("turn-state-compact-123")
);
assert_eq!(
response
.headers()
.get(EXECUTION_PATH_HEADER)
.and_then(|value| value.to_str().ok()),
Some(EXECUTION_PATH_EXECUTION_RUNTIME_STREAM)
Some(EXECUTION_PATH_EXECUTION_RUNTIME_SYNC)
);
let body: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(
body["output"][0]["encrypted_content"],
"encrypted-compact-history"
);
let body = response.text().await.expect("body should read");
assert!(body.contains("event: response.completed"));
assert!(body.contains("\"model\":\"gpt-5-upstream\""));
let seen_execution_runtime_request = seen_execution_runtime
.lock()
@@ -505,13 +560,36 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
seen_execution_runtime_request.url,
"https://api.openai.example/custom/v1/responses/compact"
);
assert_eq!(seen_execution_runtime_request.model, "gpt-5-upstream");
assert!(seen_execution_runtime_request.stream);
assert_eq!(seen_execution_runtime_request.accept, "text/event-stream");
assert_eq!(
seen_execution_runtime_request.model,
"deployment-production"
);
assert!(seen_execution_runtime_request.content_encoding.is_empty());
assert!(!seen_execution_runtime_request.stream);
assert_ne!(seen_execution_runtime_request.accept, "text/event-stream");
assert_eq!(
seen_execution_runtime_request.turn_state,
"turn-state-inbound-123"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer sk-upstream-openai-compact"
);
assert_eq!(
seen_execution_runtime_request.chatgpt_account_id,
"acc-compact-local-123"
);
assert_eq!(seen_execution_runtime_request.fedramp, "true");
assert_eq!(seen_execution_runtime_request.responses_lite, "true");
assert_eq!(
seen_execution_runtime_request.session_id,
"session-compact-local-123"
);
assert_eq!(
seen_execution_runtime_request.thread_id,
"thread-compact-local-123"
);
assert!(!seen_execution_runtime_request.x_client_request_id_present);
assert_eq!(
seen_execution_runtime_request.endpoint_tag,
"openai-compact-local"
@@ -525,20 +603,61 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
"rename-openai-compact"
);
assert!(!seen_execution_runtime_request.dropped_header_present);
assert_eq!(
seen_execution_runtime_request.instructions,
"You are GPT-5."
);
assert_eq!(seen_execution_runtime_request.metadata_mode, "safe");
assert_eq!(
seen_execution_runtime_request.metadata_source,
"desktop-openai-compact"
);
assert_eq!(
seen_execution_runtime_request.metadata_origin,
"from-original"
);
assert!(seen_execution_runtime_request.instructions.is_empty());
assert!(!seen_execution_runtime_request.store_present);
for field in [
"client_metadata",
"include",
"store",
"stream",
"stream_options",
"tool_choice",
] {
assert!(
seen_execution_runtime_request.body.get(field).is_none(),
"Compact request must omit {field}"
);
}
assert_eq!(
seen_execution_runtime_request.body["parallel_tool_calls"],
json!(false)
);
assert_eq!(
seen_execution_runtime_request.body["reasoning"]["effort"],
json!("high")
);
assert_eq!(
seen_execution_runtime_request.body["text"]["verbosity"],
json!("medium")
);
assert_eq!(
seen_execution_runtime_request.body["reasoning"]["context"],
json!("all_turns")
);
assert_eq!(
seen_execution_runtime_request.body["input"][0]["type"],
json!("additional_tools")
);
assert_eq!(
seen_execution_runtime_request.body["input"][0]["tools"][0]["name"],
json!("lookup")
);
assert_eq!(
seen_execution_runtime_request.body["input"][1]["role"],
json!("developer")
);
assert_eq!(
seen_execution_runtime_request.body["input"][1]["content"][0]["text"],
json!("Use the configured tools.")
);
assert_eq!(
seen_execution_runtime_request.body["input"][2]["content"][0]["text"],
json!("hello")
);
assert_eq!(
seen_execution_runtime_request.body["prompt_cache_key"],
json!("session:compact-e2e")
);
assert_eq!(
seen_execution_runtime_request.proxy_node_id,
"proxy-node-openai-compact-local"
@@ -558,7 +677,7 @@ async fn gateway_executes_openai_responses_compact_stream_via_local_decision_gat
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!*seen_report.lock().expect("mutex should lock"),
"report-stream should stay local when request candidate persistence is available"
"report-sync should stay local when request candidate persistence is available"
);
assert_eq!(*decision_hits.lock().expect("mutex should lock"), 0);
@@ -38,10 +38,24 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
trace_id: String,
url: String,
model: String,
content_encoding: String,
stream: bool,
accept: String,
authorization: String,
chatgpt_account_id: String,
fedramp: String,
x_client_request_id: String,
session_id: String,
thread_id: String,
prompt_cache_key: String,
responses_lite: String,
has_top_level_tools: bool,
has_top_level_instructions: bool,
has_additional_tools: bool,
parallel_tool_calls: bool,
reasoning_effort: String,
reasoning_context: String,
has_compaction_trigger: bool,
}
#[derive(Debug, Clone)]
@@ -74,7 +88,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
false,
Some(serde_json::json!(["openai", "codex"])),
Some(serde_json::json!(["openai:responses"])),
Some(serde_json::json!(["gpt-5.4"])),
Some(serde_json::json!(["gpt-5.6-sol"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
@@ -85,7 +99,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
Some(4_102_444_800_i64),
Some(serde_json::json!(["openai", "codex"])),
Some(serde_json::json!(["openai:responses"])),
Some(serde_json::json!(["gpt-5.4"])),
Some(serde_json::json!(["gpt-5.6-sol"])),
)
.expect("auth snapshot should build")
}
@@ -113,12 +127,12 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
key_global_priority_by_format: Some(serde_json::json!({"openai:responses": 1})),
model_id: "model-codex-cli-stream-local-1".to_string(),
global_model_id: "global-model-codex-cli-stream-local-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
global_model_name: "gpt-5.6-sol".to_string(),
global_model_mappings: None,
global_model_supports_streaming: Some(true),
model_provider_model_name: "gpt-5.4".to_string(),
model_provider_model_name: "gpt-5.6-sol".to_string(),
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
name: "gpt-5.4".to_string(),
name: "gpt-5.6-sol".to_string(),
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
@@ -176,7 +190,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
fn sample_provider_catalog_key() -> StoredProviderCatalogKey {
let encrypted_auth_config = encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
r#"{"provider_type":"codex","refresh_token":"rt-codex-stream-local-123"}"#,
r#"{"provider_type":"codex","refresh_token":"rt-codex-stream-local-123","account_id":"acc-codex-stream-local-123","is_fedramp":true}"#,
)
.expect("auth config should encrypt");
StoredProviderCatalogKey::new(
@@ -367,6 +381,11 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
content_encoding: payload
.get("content_encoding")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
stream: payload
.get("stream")
.and_then(|value| value.as_bool())
@@ -383,16 +402,106 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
chatgpt_account_id: payload
.get("headers")
.and_then(|value| value.get("chatgpt-account-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
fedramp: payload
.get("headers")
.and_then(|value| value.get("x-openai-fedramp"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_request_id: payload
.get("headers")
.and_then(|value| value.get("x-client-request-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
session_id: payload
.get("headers")
.and_then(|value| value.get("session-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
thread_id: payload
.get("headers")
.and_then(|value| value.get("thread-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
prompt_cache_key: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("prompt_cache_key"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
responses_lite: payload
.get("headers")
.and_then(|value| {
value.get("x-openai-internal-codex-responses-lite")
})
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
has_top_level_tools: payload
.get("body")
.and_then(|value| value.get("json_body"))
.is_some_and(|body| body.get("tools").is_some()),
has_top_level_instructions: payload
.get("body")
.and_then(|value| value.get("json_body"))
.is_some_and(|body| body.get("instructions").is_some()),
has_additional_tools: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("input"))
.and_then(|value| value.as_array())
.and_then(|input| input.first())
.and_then(|item| item.get("type"))
.and_then(|value| value.as_str())
== Some("additional_tools"),
parallel_tool_calls: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("parallel_tool_calls"))
.and_then(|value| value.as_bool())
.unwrap_or(true),
reasoning_effort: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("reasoning"))
.and_then(|value| value.get("effort"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
reasoning_context: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("reasoning"))
.and_then(|value| value.get("context"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
has_compaction_trigger: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("input"))
.and_then(|value| value.as_array())
.is_some_and(|input| {
input.iter().any(|item| {
item.get("type").and_then(|value| value.as_str())
== Some("compaction_trigger")
})
}),
});
let frames = concat!(
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.completed\\ndata: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_codex_cli_stream_local_123\\\",\\\"object\\\":\\\"response\\\",\\\"model\\\":\\\"gpt-5.4\\\",\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"input_tokens\\\":1,\\\"output_tokens\\\":2,\\\"total_tokens\\\":3}}}\\n\\n\"}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.output_item.done\\ndata: {\\\"type\\\":\\\"response.output_item.done\\\",\\\"item\\\":{\\\"type\\\":\\\"compaction\\\",\\\"encrypted_content\\\":\\\"ENCRYPTED_CONTEXT_COMPACTION_SUMMARY\\\"}}\\n\\n\"}}\n",
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"event: response.completed\\ndata: {\\\"type\\\":\\\"response.completed\\\",\\\"response\\\":{\\\"id\\\":\\\"resp_codex_cli_stream_local_123\\\",\\\"object\\\":\\\"response\\\",\\\"model\\\":\\\"gpt-5.6-sol\\\",\\\"status\\\":\\\"completed\\\",\\\"usage\\\":{\\\"input_tokens\\\":1,\\\"output_tokens\\\":2,\\\"total_tokens\\\":3}}}\\n\\n\"}}\n",
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":41}}}\n",
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
);
@@ -469,8 +578,16 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
http::header::AUTHORIZATION,
format!("Bearer {client_api_key}"),
)
.header("session-id", "session-codex-stream-local-123")
.header("thread-id", "thread-codex-stream-local-123")
.header(
"x-client-request-id",
"thread-codex-stream-local-123",
)
.header(TRACE_ID_HEADER, "trace-codex-cli-stream-local-123")
.body("{\"model\":\"gpt-5.4\",\"input\":\"hello\",\"stream\":true}")
.body(
r#"{"model":"gpt-5.6-sol","instructions":"Use the configured tools.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"compact"}]},{"type":"compaction_trigger"}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"parallel_tool_calls":true,"prompt_cache_key":"thread-codex-stream-local-123","client_metadata":{"session_id":"session-codex-stream-local-123","thread_id":"thread-codex-stream-local-123"},"stream":true}"#,
)
.send()
.await
.expect("request should succeed");
@@ -478,9 +595,20 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
assert_eq!(response.status(), StatusCode::OK);
let response_body =
strip_sse_keepalive_comments(&response.text().await.expect("body should read"));
assert!(response_body.contains("event: response.output_item.done\n"));
assert!(response_body.contains("\"type\":\"compaction\""));
assert!(response_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"));
let data_line = response_body
.lines()
.find_map(|line| line.strip_prefix("data: "))
.filter_map(|line| line.strip_prefix("data: "))
.find(|line| {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|event| event.get("type").cloned())
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.as_deref()
== Some("response.completed")
})
.expect("completed event data should exist");
let completed_event: serde_json::Value =
serde_json::from_str(data_line).expect("completed event should parse");
@@ -495,7 +623,7 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
"response": {
"id": "resp_codex_cli_stream_local_123",
"object": "response",
"model": "gpt-5.4",
"model": "gpt-5.6-sol",
"status": "completed",
"usage": {
"input_tokens": 1,
@@ -542,7 +670,8 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
seen_execution_runtime_request.url,
"https://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(seen_execution_runtime_request.model, "gpt-5.4");
assert_eq!(seen_execution_runtime_request.model, "gpt-5.6-sol");
assert_eq!(seen_execution_runtime_request.content_encoding, "zstd");
assert!(seen_execution_runtime_request.stream);
assert_eq!(seen_execution_runtime_request.accept, "text/event-stream");
assert_eq!(
@@ -550,9 +679,37 @@ async fn gateway_executes_codex_cli_stream_via_local_decision_gate_after_oauth_r
"Bearer refreshed-codex-stream-access-token"
);
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"trace-codex-cli-stream-local-123"
seen_execution_runtime_request.chatgpt_account_id,
"acc-codex-stream-local-123"
);
assert_eq!(seen_execution_runtime_request.fedramp, "true");
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"thread-codex-stream-local-123"
);
assert_eq!(
seen_execution_runtime_request.session_id,
"session-codex-stream-local-123"
);
assert_eq!(
seen_execution_runtime_request.thread_id,
"thread-codex-stream-local-123"
);
assert_eq!(
seen_execution_runtime_request.thread_id,
seen_execution_runtime_request.prompt_cache_key
);
assert_eq!(seen_execution_runtime_request.responses_lite, "true");
assert!(!seen_execution_runtime_request.has_top_level_tools);
assert!(!seen_execution_runtime_request.has_top_level_instructions);
assert!(seen_execution_runtime_request.has_additional_tools);
assert!(!seen_execution_runtime_request.parallel_tool_calls);
assert_eq!(seen_execution_runtime_request.reasoning_effort, "low");
assert_eq!(
seen_execution_runtime_request.reasoning_context,
"all_turns"
);
assert!(seen_execution_runtime_request.has_compaction_trigger);
let stored_candidates = request_candidate_repository
.list_by_request_id("trace-codex-cli-stream-local-123")
@@ -3223,6 +3223,9 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
model: String,
authorization: String,
x_client_request_id: String,
session_id: String,
thread_id: String,
prompt_cache_key: String,
stream_present: bool,
plan_stream: bool,
}
@@ -3509,6 +3512,25 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
session_id: payload
.get("headers")
.and_then(|value| value.get("session-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
thread_id: payload
.get("headers")
.and_then(|value| value.get("thread-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
prompt_cache_key: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("prompt_cache_key"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
stream_present: payload
.get("body")
.and_then(|value| value.get("json_body"))
@@ -3646,7 +3668,16 @@ async fn gateway_executes_codex_cli_sync_via_local_decision_gate_after_oauth_ref
);
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"trace-codex-cli-local-123"
seen_execution_runtime_request.thread_id
);
assert_eq!(
seen_execution_runtime_request.session_id,
seen_execution_runtime_request.thread_id
);
assert!(seen_execution_runtime_request.prompt_cache_key.is_empty());
assert_ne!(
seen_execution_runtime_request.thread_id,
seen_execution_runtime_request.trace_id
);
assert!(seen_execution_runtime_request.stream_present);
assert!(seen_execution_runtime_request.plan_stream);
@@ -2,7 +2,6 @@ use super::{
any, build_router_with_state, build_state_with_execution_runtime_override, json, start_server,
to_bytes, Arc, Body, Json, Mutex, Request, Router, StatusCode, TRACE_ID_HEADER,
};
use crate::ai_serving::CODEX_OPENAI_IMAGE_INTERNAL_MODEL;
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
use aether_data::repository::auth::{
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
@@ -411,10 +410,10 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
url: String,
authorization: String,
model: String,
action: String,
prompt: String,
image_url: String,
request_stream: bool,
body_stream: Option<bool>,
}
fn hash_api_key(value: &str) -> String {
@@ -574,12 +573,6 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
.and_then(|value| value.get("json_body"))
.cloned()
.unwrap_or_else(|| json!({}));
let content = body_json
.get("input")
.and_then(|value| value.get(0))
.and_then(|value| value.get("content"))
.cloned()
.unwrap_or_else(|| json!([]));
*seen_execution_runtime_inner
.lock()
.expect("mutex should lock") = Some(SeenExecutionRuntimeSyncRequest {
@@ -605,39 +598,22 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
action: body_json
.get("tools")
.and_then(|value| value.get(0))
.and_then(|value| value.get("action"))
prompt: body_json
.get("prompt")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
prompt: content
.as_array()
.into_iter()
.flatten()
.find(|item| {
item.get("type").and_then(|value| value.as_str()) == Some("input_text")
})
.and_then(|item| item.get("text"))
image_url: body_json
.get("image")
.and_then(|value| value.get("image_url"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
image_url: content
.as_array()
.into_iter()
.flatten()
.find(|item| {
item.get("type").and_then(|value| value.as_str()) == Some("input_image")
})
.and_then(|item| item.get("image_url"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
request_stream: body_json
request_stream: payload
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(true),
body_stream: body_json.get("stream").and_then(serde_json::Value::as_bool),
});
Json(json!({
"request_id": "trace-gemini-image-to-openai-123",
@@ -647,21 +623,16 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
},
"body": {
"json_body": {
"id": "resp_img_bridge_123",
"object": "response",
"created": 1776839946,
"model": "gpt-image-2-upstream",
"status": "completed",
"usage": {
"input_tokens": 3,
"output_tokens": 4,
"total_tokens": 7
},
"output": [{
"type": "image_generation_call",
"status": "completed",
"output_format": "png",
"data": [{
"revised_prompt": "converted gemini prompt",
"result": "aGVsbG8="
"b64_json": "aGVsbG8="
}]
}
},
@@ -750,14 +721,13 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
);
assert_eq!(
seen_execution_runtime_request.url,
"https://api.openai.com/v1/images/generations"
"https://api.openai.com/v1/images/edits"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer sk-upstream-openai-image"
);
assert_eq!(seen_execution_runtime_request.model, "gpt-image-2-upstream");
assert_eq!(seen_execution_runtime_request.action, "edit");
assert_eq!(
seen_execution_runtime_request.prompt,
"Change the background"
@@ -767,6 +737,7 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
"data:image/png;base64,aGVsbG8="
);
assert!(!seen_execution_runtime_request.request_stream);
assert_eq!(seen_execution_runtime_request.body_stream, None);
gateway_handle.abort();
execution_runtime_handle.abort();
@@ -785,18 +756,9 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
struct SeenExecutionRuntimeSyncRequest {
trace_id: String,
url: String,
model: String,
authorization: String,
x_client_request_id: String,
prompt: String,
content_is_string: bool,
tool_type: String,
tool_size: String,
tool_quality: String,
tool_background: String,
tool_choice_type: String,
tool_has_n: bool,
request_stream: bool,
headers: serde_json::Value,
body: serde_json::Value,
plan_stream: bool,
}
@@ -1011,98 +973,18 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
model: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("model"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
authorization: payload
.get("headers")
.and_then(|value| value.get("authorization"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
x_client_request_id: payload
.get("headers")
.and_then(|value| value.get("x-client-request-id"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
prompt: payload
headers: payload.get("headers").cloned().unwrap_or_default(),
body: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("input"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("content"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
content_is_string: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("input"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("content"))
.is_some_and(|value| value.is_string()),
tool_type: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_size: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("size"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_quality: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("quality"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_background: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("background"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_choice_type: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tool_choice"))
.and_then(|value| value.get("type"))
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string(),
tool_has_n: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("tools"))
.and_then(|value| value.get(0))
.and_then(|value| value.as_object())
.is_some_and(|object| object.contains_key("n")),
request_stream: payload
.get("body")
.and_then(|value| value.get("json_body"))
.and_then(|value| value.get("stream"))
.and_then(|value| value.as_bool())
.unwrap_or(false),
.cloned()
.unwrap_or_default(),
plan_stream: payload
.get("stream")
.and_then(|value| value.as_bool())
@@ -1112,20 +994,11 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
"request_id": "trace-codex-image-local-123",
"status_code": 200,
"headers": {
"content-type": "text/event-stream"
"content-type": "application/json"
},
"body": {
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
concat!(
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_img_123\",\"created_at\":1776839946}}\n\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"status\":\"generating\",\"output_format\":\"png\",\"quality\":\"medium\",\"size\":\"1024x1024\",\"revised_prompt\":\"中国历史视觉海报\",\"result\":\"aGVsbG8=\"}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_123\",\"object\":\"response\",\"model\":\"__CODEX_IMAGE_MODEL__\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":2440,\"output_tokens\":184,\"total_tokens\":2624},\"tool_usage\":{\"image_gen\":{\"input_tokens\":171,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":171},\"output_tokens\":1372,\"output_tokens_details\":{\"image_tokens\":1372,\"text_tokens\":0},\"total_tokens\":1543}}}}\n\n",
"data: [DONE]\n\n"
)
.replace(
"__CODEX_IMAGE_MODEL__",
CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
)
r#"{"created":1776839946,"data":[{"b64_json":"aGVsbG8=","revised_prompt":"水墨视觉海报"}],"usage":{"input_tokens":171,"output_tokens":1372,"total_tokens":1543}}"#
)
},
"telemetry": {
@@ -1182,7 +1055,7 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
format!("Bearer {client_api_key}"),
)
.header(TRACE_ID_HEADER, "trace-codex-image-local-123")
.body("{\"model\":\"gpt-image-2\",\"prompt\":\"生成一张中国历史视觉海报\",\"size\":\"1024x1024\",\"n\":1,\"response_format\":\"b64_json\"}")
.body("{\"model\":\"gpt-image-2\",\"prompt\":\"生成一张水墨视觉海报\",\"background\":\"auto\",\"quality\":\"auto\",\"size\":\"auto\",\"n\":1,\"response_format\":\"b64_json\"}")
.send()
.await
.expect("request should succeed");
@@ -1191,10 +1064,7 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
let response_json: serde_json::Value = response.json().await.expect("body should parse");
assert_eq!(response_json["created"], 1776839946);
assert_eq!(response_json["data"][0]["b64_json"], "aGVsbG8=");
assert_eq!(
response_json["data"][0]["revised_prompt"],
"中国历史视觉海报"
);
assert_eq!(response_json["data"][0]["revised_prompt"], "水墨视觉海报");
assert_eq!(response_json["usage"]["input_tokens"], 171);
assert_eq!(response_json["usage"]["output_tokens"], 1372);
@@ -1229,35 +1099,34 @@ async fn gateway_executes_codex_image_sync_via_local_decision_gate_after_oauth_r
);
assert_eq!(
seen_execution_runtime_request.url,
"https://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(
seen_execution_runtime_request.model,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL
"https://chatgpt.com/backend-api/codex/images/generations"
);
assert_eq!(
seen_execution_runtime_request.authorization,
"Bearer refreshed-codex-image-access-token"
);
assert_eq!(
seen_execution_runtime_request.x_client_request_id,
"trace-codex-image-local-123"
seen_execution_runtime_request.body,
json!({
"prompt": "生成一张水墨视觉海报",
"background": "auto",
"model": "gpt-image-2",
"n": 1,
"quality": "auto",
"size": "auto"
})
);
assert_eq!(
seen_execution_runtime_request.prompt,
"生成一张中国历史视觉海报"
seen_execution_runtime_request.headers["user-agent"],
"codex_cli_rs/0.144.1"
);
assert!(seen_execution_runtime_request.content_is_string);
assert_eq!(seen_execution_runtime_request.tool_type, "image_generation");
assert_eq!(seen_execution_runtime_request.tool_size, "1024x1024");
assert_eq!(seen_execution_runtime_request.tool_quality, "high");
assert_eq!(seen_execution_runtime_request.tool_background, "auto");
assert_eq!(
seen_execution_runtime_request.tool_choice_type,
"image_generation"
seen_execution_runtime_request.headers["originator"],
"codex_cli_rs"
);
assert!(!seen_execution_runtime_request.tool_has_n);
assert!(seen_execution_runtime_request.request_stream);
for header in ["x-client-request-id", "session-id", "thread-id"] {
assert!(seen_execution_runtime_request.headers.get(header).is_none());
}
assert!(!seen_execution_runtime_request.plan_stream);
let persisted_transport_state =
@@ -66,6 +66,7 @@ fn sample_decision() -> crate::control::GatewayControlDecision {
auth_context: None,
admin_principal: None,
local_auth_rejection: None,
model_directive_policy: Default::default(),
}
}
@@ -559,7 +559,7 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
.expect("mutex should lock") += 1;
assert_eq!(
plan.url,
"https://chatgpt.com/backend-api/codex/models?client_version=0.128.0-alpha.1"
"https://chatgpt.com/backend-api/codex/models?client_version=0.144.1"
);
Json(json!({
"request_id": "req-provider-query-codex-invalidated",
@@ -625,6 +625,11 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["success"], json!(true));
assert_eq!(payload["data"]["error"], serde_json::Value::Null);
let warning = payload["data"]["warning"]
.as_str()
.expect("Codex fallback warning should be present");
assert!(warning.contains("Codex 动态模型目录不可用"));
assert!(warning.contains("invalidated"));
let model_ids = payload["data"]["models"]
.as_array()
.expect("models should be an array")
@@ -634,11 +639,14 @@ async fn gateway_handles_admin_provider_query_models_falls_back_to_codex_preset_
assert_eq!(
model_ids,
vec![
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"codex-auto-review",
"gpt-5.2",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
"gpt-5.6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
]
);
assert_eq!(
@@ -3837,13 +3845,12 @@ async fn gateway_handles_openai_responses_test_model_locally_impl() {
.and_then(|value| value.as_str()),
Some(prompt)
);
assert_eq!(
plan.body
.json_body
.as_ref()
.and_then(|body| body.get("instructions")),
Some(&json!(""))
);
assert!(plan
.body
.json_body
.as_ref()
.and_then(|body| body.get("instructions"))
.is_none());
assert_eq!(
plan.body
.json_body
@@ -3856,7 +3863,7 @@ async fn gateway_handles_openai_responses_test_model_locally_impl() {
.json_body
.as_ref()
.and_then(|body| body.get("prompt_cache_key"))
.is_some());
.is_none());
Json(json!({
"request_id": plan.request_id,
"candidate_id": plan.candidate_id,
@@ -3960,8 +3967,8 @@ async fn gateway_handles_openai_image_test_model_locally_impl() {
assert_eq!(plan.client_api_format, "openai:image");
assert_eq!(plan.provider_api_format, "openai:image");
assert_eq!(plan.model_name.as_deref(), Some("gpt-image-1"));
assert_eq!(plan.url, "https://api.openai.example/v1/responses");
assert!(plan.stream);
assert_eq!(plan.url, "https://api.openai.example/v1/images/generations");
assert!(!plan.stream);
assert_eq!(
plan.headers.get("authorization").map(String::as_str),
Some("Bearer sk-test-image")
@@ -3971,38 +3978,42 @@ async fn gateway_handles_openai_image_test_model_locally_impl() {
.json_body
.as_ref()
.and_then(|body| body.get("model")),
Some(&json!(crate::ai_serving::CODEX_OPENAI_IMAGE_INTERNAL_MODEL))
Some(&json!("gpt-image-1"))
);
assert_eq!(
plan.body
.json_body
.as_ref()
.and_then(|body| body.get("input"))
.and_then(|input| input.as_array())
.and_then(|items| items.first())
.and_then(|item| item.get("content"))
.and_then(|body| body.get("prompt"))
.and_then(|value| value.as_str()),
Some("Draw a small blue square")
);
assert!(plan
.body
.json_body
.as_ref()
.is_some_and(|body| body.get("stream").is_none()));
Json(json!({
"request_id": plan.request_id,
"candidate_id": plan.candidate_id,
"status_code": 200,
"headers": {
"content-type": "text/event-stream"
"content-type": "application/json"
},
"body": {
"body_bytes_b64": base64::engine::general_purpose::STANDARD.encode(
concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"created_at\":1776839946}}\n\n",
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"image_generation_call\",\"output_format\":\"png\",\"revised_prompt\":\"revised prompt\",\"result\":\"aGVsbG8=\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img_123\",\"model\":\"gpt-image-1\",\"status\":\"completed\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":171,\"output_tokens\":1372,\"total_tokens\":1543}}}}\n\n"
)
.as_bytes()
)
"json_body": {
"created": 1776839946,
"model": "gpt-image-1",
"data": [{
"b64_json": "aGVsbG8=",
"revised_prompt": "revised prompt"
}],
"usage": {
"input_tokens": 171,
"output_tokens": 1372,
"total_tokens": 1543
}
}
},
"telemetry": {
"elapsed_ms": 19
@@ -1122,7 +1122,7 @@ async fn gateway_creates_admin_provider_locally_with_trusted_admin_principal() {
.as_ref()
.and_then(|value| value.get("upstream_stream_policy"))
.and_then(serde_json::Value::as_str),
Some("force_stream")
None
);
assert!(responses_endpoint.body_rules.is_none());
assert!(compact_endpoint.body_rules.is_none());
@@ -1248,7 +1248,7 @@ async fn gateway_updates_fixed_provider_and_reconciles_template_managed_endpoint
.as_ref()
.and_then(|value| value.get("upstream_stream_policy"))
.and_then(serde_json::Value::as_str),
Some("force_stream")
None
);
let keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-codex".to_string()])
@@ -1379,7 +1379,7 @@ async fn gateway_handles_admin_stats_leaderboard_models_locally_with_trusted_adm
assert_eq!(payload["metric"], "tokens");
assert_eq!(payload["items"][0]["rank"], 1);
assert_eq!(payload["items"][0]["id"], "gpt-5");
assert_eq!(payload["items"][0]["value"], 160);
assert_eq!(payload["items"][0]["value"], 150);
assert_eq!(payload["items"][1]["id"], "claude-3-5-sonnet");
assert_eq!(payload["items"][1]["value"], 100);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -641,7 +641,7 @@ async fn gateway_handles_admin_usage_aggregation_stats_locally_with_trusted_admi
assert_eq!(items[0]["model"], "gpt-5");
assert_eq!(items[0]["request_count"], 2);
assert_eq!(items[0]["output_tokens"], 40);
assert_eq!(items[0]["effective_input_tokens"], 150);
assert_eq!(items[0]["effective_input_tokens"], 120);
assert_eq!(items[0]["total_input_context"], 160);
assert_eq!(items[0]["cache_creation_tokens"], 30);
assert_eq!(items[0]["cache_creation_ephemeral_5m_tokens"], 12);
@@ -1026,7 +1026,7 @@ async fn gateway_handles_admin_usage_active_locally_with_trusted_admin_principal
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["requests"].as_array().expect("array").len(), 1);
assert_eq!(payload["requests"][0]["id"], "usage-pending");
assert_eq!(payload["requests"][0]["effective_input_tokens"], 5);
assert_eq!(payload["requests"][0]["effective_input_tokens"], 0);
assert_eq!(payload["requests"][0]["provider"], "OpenAI");
assert_eq!(payload["requests"][0]["api_key_name"], "fresh-primary");
assert_eq!(payload["requests"][0]["has_fallback"], true);
@@ -1326,7 +1326,7 @@ async fn gateway_handles_admin_usage_records_locally_with_trusted_admin_principa
payload["records"][0]["provider_key_name"],
"upstream-primary"
);
assert_eq!(payload["records"][0]["effective_input_tokens"], 35);
assert_eq!(payload["records"][0]["effective_input_tokens"], 20);
assert_eq!(payload["records"][0]["first_byte_time_ms"], 120);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -2053,8 +2053,8 @@ async fn gateway_handles_admin_usage_detail_locally_with_trusted_admin_principal
assert_eq!(payload["api_key"]["name"], "primary");
assert_eq!(payload["provider"], "OpenAI");
assert_eq!(payload["model"], "gpt-5");
assert_eq!(payload["effective_input_tokens"], 115);
assert_eq!(payload["total_tokens"], 165);
assert_eq!(payload["effective_input_tokens"], 100);
assert_eq!(payload["total_tokens"], 150);
assert_eq!(payload["cache_creation_cost"], 0.0);
assert_eq!(payload["cache_read_cost"], 0.0);
assert_eq!(
@@ -35,6 +35,27 @@ use aether_data_contracts::repository::video_tasks::{
use base64::Engine as _;
use sha2::{Digest, Sha256};
fn run_frontdoor_async_test<F>(name: &'static str, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let handle = std::thread::Builder::new()
.name(name.to_string())
.stack_size(16 * 1024 * 1024)
.spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("frontdoor test runtime should build")
.block_on(future);
})
.expect("large-stack frontdoor test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
fn hash_api_key(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
+208 -4
View File
@@ -1,8 +1,8 @@
use super::{
hash_api_key, sample_models_candidate_row, unrestricted_models_snapshot,
InMemoryAuthApiKeySnapshotRepository, InMemoryMinimalCandidateSelectionReadRepository,
InMemoryVideoTaskRepository, UpsertVideoTask, VideoTaskLookupKey, VideoTaskReadRepository,
VideoTaskStatus, VideoTaskWriteRepository, DEVELOPMENT_ENCRYPTION_KEY,
InMemoryVideoTaskRepository, StoredAuthApiKeySnapshot, UpsertVideoTask, VideoTaskLookupKey,
VideoTaskReadRepository, VideoTaskStatus, VideoTaskWriteRepository, DEVELOPMENT_ENCRYPTION_KEY,
};
use crate::image_capabilities::openai_image_gateway_max_generation_count;
use crate::tests::{
@@ -26,6 +26,95 @@ use std::collections::HashMap;
use std::future::pending;
use std::sync::atomic::{AtomicBool, Ordering};
fn codex_models_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(json!(["codex"])),
Some(json!(["openai:responses"])),
Some(json!(["frontier-sol", "broken-luna"])),
api_key_id.to_string(),
Some("codex-models".to_string()),
true,
false,
false,
Some(10),
Some(5),
Some(4_102_444_800),
Some(json!(["codex"])),
Some(json!(["openai:responses"])),
Some(json!(["frontier-sol", "broken-luna"])),
)
.expect("Codex models auth snapshot should build")
}
fn sample_codex_models_candidate_row(
provider_id: &str,
global_model_name: &str,
source_model_name: &str,
) -> StoredMinimalCandidateSelectionRow {
let mut row = sample_models_candidate_row(
provider_id,
"codex",
"openai:responses",
global_model_name,
10,
);
row.provider_type = "codex".to_string();
row.key_auth_type = "oauth".to_string();
row.model_provider_model_name = source_model_name.to_string();
row.model_provider_model_mappings = Some(vec![
aether_data_contracts::repository::candidate_selection::StoredProviderModelMapping {
name: source_model_name.to_string(),
priority: 1,
api_formats: Some(vec!["openai:responses".to_string()]),
endpoint_ids: None,
},
]);
row
}
fn complete_codex_model_card(source_model_name: &str) -> serde_json::Value {
json!({
"id": source_model_name,
"api_formats": ["openai:responses"],
"slug": source_model_name,
"display_name": "GPT-5.6-Sol",
"description": "Frontier coding model",
"default_reasoning_level": "low",
"supported_reasoning_levels": [
{"effort": "low", "description": "Low"},
{"effort": "medium", "description": "Medium"},
{"effort": "high", "description": "High"},
{"effort": "xhigh", "description": "XHigh"},
{"effort": "max", "description": "Max"},
{"effort": "ultra", "description": "Ultra"}
],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"availability_nux": null,
"upgrade": null,
"base_instructions": "Use the current Codex instructions.",
"model_messages": null,
"supports_reasoning_summaries": true,
"support_verbosity": true,
"default_verbosity": "low",
"apply_patch_tool_type": "freeform",
"truncation_policy": {"mode": "tokens", "limit": 10000},
"supports_parallel_tool_calls": true,
"experimental_supported_tools": [],
"minimal_client_version": "0.144.0",
"future_capability": {"enabled": true}
})
}
fn gemini_operation_status_label(status: VideoTaskStatus) -> &'static str {
match status {
VideoTaskStatus::Pending => "Pending",
@@ -360,6 +449,121 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
fallback_probe_handle.abort();
}
#[tokio::test]
async fn gateway_serves_codex_model_cards_for_versioned_models_requests() {
let codex_row =
sample_codex_models_candidate_row("provider-codex-models", "frontier-sol", "gpt-5.6-sol");
let incomplete_codex_row = sample_codex_models_candidate_row(
"provider-codex-incomplete",
"broken-luna",
"gpt-5.6-luna",
);
let candidate_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
codex_row.clone(),
incomplete_codex_row.clone(),
sample_models_candidate_row(
"provider-openai-responses",
"openai",
"openai:responses",
"custom-responses-model",
20,
),
]));
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![
(
Some(hash_api_key("sk-codex-models")),
codex_models_snapshot("key-codex-models", "user-codex-models"),
),
(
Some(hash_api_key("sk-standard-models")),
unrestricted_models_snapshot("key-standard-models", "user-standard-models"),
),
]));
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(
crate::data::GatewayDataState::with_minimal_candidate_selection_and_auth_for_tests(
candidate_repository,
auth_repository,
),
);
state
.runtime_kv_setex(
&format!(
"upstream_models:{}:{}",
codex_row.provider_id, codex_row.key_id
),
&serde_json::to_string(&vec![complete_codex_model_card("gpt-5.6-sol")])
.expect("model cache should serialize"),
60,
)
.await
.expect("model cache should seed");
state
.runtime_kv_setex(
&format!(
"upstream_models:{}:{}",
incomplete_codex_row.provider_id, incomplete_codex_row.key_id
),
&serde_json::to_string(&vec![json!({
"id": "gpt-5.6-luna",
"slug": "gpt-5.6-luna",
"display_name": "GPT-5.6-Luna"
})])
.expect("incomplete model cache should serialize"),
60,
)
.await
.expect("incomplete model cache should seed");
let gateway = build_router_with_state(state);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
let codex_response = client
.get(format!("{gateway_url}/v1/models?client_version=0.144.1"))
.header("authorization", "Bearer sk-codex-models")
.send()
.await
.expect("Codex models request should succeed");
assert_eq!(codex_response.status(), StatusCode::OK);
let codex_payload: serde_json::Value = codex_response
.json()
.await
.expect("Codex models body should parse");
assert_eq!(codex_payload["models"].as_array().map(Vec::len), Some(1));
assert_eq!(codex_payload["models"][0]["slug"], "frontier-sol");
assert_eq!(
codex_payload["models"][0]["supported_reasoning_levels"][5]["effort"],
"ultra"
);
assert_eq!(
codex_payload["models"][0]["future_capability"],
json!({"enabled": true})
);
assert!(codex_payload["models"][0].get("id").is_none());
assert!(codex_payload["models"][0].get("api_formats").is_none());
assert!(codex_payload.get("object").is_none());
let standard_response = client
.get(format!("{gateway_url}/v1/models"))
.header("authorization", "Bearer sk-standard-models")
.send()
.await
.expect("standard models request should succeed");
assert_eq!(standard_response.status(), StatusCode::OK);
let standard_payload: serde_json::Value = standard_response
.json()
.await
.expect("standard models body should parse");
assert_eq!(standard_payload["object"], "list");
assert!(standard_payload["data"].is_array());
assert!(standard_payload.get("models").is_none());
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_openai_models_list_drops_disabled_global_model_after_cache_invalidation() {
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
@@ -1212,7 +1416,7 @@ async fn gateway_does_not_locally_reject_image_model_name_on_chat_completions()
}
#[tokio::test]
async fn gateway_rejects_image_request_with_n_greater_than_four_without_hitting_fallback_probe() {
async fn gateway_rejects_image_request_above_gateway_limit_without_hitting_fallback_probe() {
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
let fallback_probe = Router::new().route(
@@ -1247,7 +1451,7 @@ async fn gateway_rejects_image_request_with_n_greater_than_four_without_hitting_
serde_json::to_vec(&json!({
"model": "grok-imagine-image-lite",
"prompt": "draw",
"n": 5,
"n": openai_image_gateway_max_generation_count() + 1,
"response_format": "b64_json"
}))
.expect("request body should encode"),
@@ -1,8 +1,9 @@
use super::{
hash_api_key, sample_endpoint, sample_key, sample_models_candidate_row, sample_provider,
unrestricted_models_snapshot, InMemoryAuthApiKeySnapshotRepository,
InMemoryMinimalCandidateSelectionReadRepository, InMemoryProviderCatalogReadRepository,
InMemoryRequestCandidateRepository, DEVELOPMENT_ENCRYPTION_KEY,
hash_api_key, run_frontdoor_async_test, sample_endpoint, sample_key,
sample_models_candidate_row, sample_provider, unrestricted_models_snapshot,
InMemoryAuthApiKeySnapshotRepository, InMemoryMinimalCandidateSelectionReadRepository,
InMemoryProviderCatalogReadRepository, InMemoryRequestCandidateRepository,
DEVELOPMENT_ENCRYPTION_KEY,
};
use crate::tests::{
any, build_router, build_router_with_state, build_state_with_execution_runtime_override, json,
@@ -160,8 +161,15 @@ async fn gateway_returns_internal_gateway_plan_sync_proxy_public_action_without_
upstream_handle.abort();
}
#[tokio::test]
async fn gateway_handles_internal_gateway_execute_sync_locally() {
#[test]
fn gateway_handles_internal_gateway_execute_sync_locally() {
run_frontdoor_async_test(
"gateway_handles_internal_gateway_execute_sync_locally",
gateway_handles_internal_gateway_execute_sync_locally_impl(),
);
}
async fn gateway_handles_internal_gateway_execute_sync_locally_impl() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let fallback_probe = Router::new().route(

Some files were not shown because too many files have changed in this diff Show More