mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
Merge PR #669: align GPT-5.6 and Codex request protocols
This commit is contained in:
@@ -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,157 @@ 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_summary_parameter")
|
||||
.is_none_or(Value::is_boolean)
|
||||
&& 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 +335,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;
|
||||
|
||||
@@ -220,14 +220,7 @@ fn users_me_usage_api_format_defaults_to_non_stream(item: &StoredRequestUsageAud
|
||||
let Some(value) = api_format else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
crate::ai_serving::normalize_api_format_alias(value).as_str(),
|
||||
"openai:chat"
|
||||
| "openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:image"
|
||||
| "claude:messages"
|
||||
)
|
||||
crate::ai_serving::api_format_defaults_to_non_stream(value)
|
||||
}
|
||||
|
||||
fn users_me_usage_request_body_implies_default_non_stream(item: &StoredRequestUsageAudit) -> bool {
|
||||
@@ -515,6 +508,9 @@ fn build_users_me_usage_record_payload(
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
payload["service_tier"] = json!(service_tier);
|
||||
}
|
||||
if let Some(actual_service_tier) = item.provider_actual_service_tier() {
|
||||
payload["actual_service_tier"] = json!(actual_service_tier);
|
||||
}
|
||||
if include_actual_cost {
|
||||
payload["actual_cost"] = json!(round_to(item.actual_total_cost_usd, 6));
|
||||
payload["rate_multiplier"] = json!(rate_multiplier);
|
||||
@@ -582,6 +578,9 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_
|
||||
if let Some(service_tier) = item.provider_service_tier() {
|
||||
payload["service_tier"] = json!(service_tier);
|
||||
}
|
||||
if let Some(actual_service_tier) = item.provider_actual_service_tier() {
|
||||
payload["actual_service_tier"] = json!(actual_service_tier);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -1840,6 +1839,27 @@ mod tests {
|
||||
assert_eq!(active_payload["client_is_stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_stream_defaults_to_non_stream_for_openai_search() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
is_stream: false,
|
||||
api_format: Some("openai:search".to_string()),
|
||||
request_body: Some(json!({
|
||||
"id": "session-search-1",
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": "current documentation"
|
||||
})),
|
||||
..sample_usage("completed")
|
||||
};
|
||||
|
||||
assert!(!users_me_usage_client_is_stream(&item));
|
||||
|
||||
let record_payload =
|
||||
build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false);
|
||||
assert_eq!(record_payload["client_requested_stream"], false);
|
||||
assert_eq!(record_payload["client_is_stream"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_usage_upstream_stream_prefers_request_metadata_flag() {
|
||||
let item = StoredRequestUsageAudit {
|
||||
|
||||
Reference in New Issue
Block a user