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
@@ -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;