mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(codex-image): 封装 GPT Image 2 图片接口并收紧错误处理
- 新增 openai:image 路由、planner 与 finalize,内部通过 Codex responses image_generation tool 执行生图 - 补充 Codex OAuth/header 兼容、图片 success report 本地处理与相关前后端/集成测试 - 禁止 chat/completions 使用 gpt-image-2,图片接口限制 n=1,并移除 Provider 模型页的图片能力开关
This commit is contained in:
@@ -92,7 +92,6 @@ pub(crate) async fn maybe_build_local_admin_provider_writes_response(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
"id": created_provider.id,
|
||||
|
||||
@@ -243,6 +243,8 @@ pub(crate) struct AdminProviderModelCreateRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) supports_extended_thinking: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) supports_image_generation: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) is_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) config: Option<serde_json::Value>,
|
||||
@@ -272,6 +274,8 @@ pub(crate) struct AdminProviderModelUpdateRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) supports_extended_thinking: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) supports_image_generation: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) is_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub(crate) is_available: Option<bool>,
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::handlers::admin::provider::write::normalize::normalize_provider_type_
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -20,12 +20,13 @@ pub(crate) fn build_admin_fixed_provider_endpoint_record(
|
||||
Some(provider.provider_type.as_str()),
|
||||
)
|
||||
.and_then(|(_, rules)| (!rules.is_empty()).then_some(serde_json::Value::Array(rules)));
|
||||
let endpoint_config =
|
||||
if provider.provider_type == "codex" && normalized_api_format == "openai:cli" {
|
||||
Some(json!({ "upstream_stream_policy": "force_stream" }))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let endpoint_config = if provider.provider_type == "codex"
|
||||
&& matches!(normalized_api_format, "openai:cli" | "openai:image")
|
||||
{
|
||||
Some(json!({ "upstream_stream_policy": "force_stream" }))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::handlers::admin::provider::write::normalize::normalize_provider_type_
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::normalize_json_object;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub(crate) async fn build_admin_update_provider_record(
|
||||
|
||||
@@ -120,6 +120,7 @@ impl<'a> AdminAppState<'a> {
|
||||
payload.supports_function_calling,
|
||||
payload.supports_streaming,
|
||||
payload.supports_extended_thinking,
|
||||
payload.supports_image_generation,
|
||||
payload.is_active,
|
||||
config,
|
||||
)
|
||||
@@ -226,6 +227,11 @@ impl<'a> AdminAppState<'a> {
|
||||
} else {
|
||||
existing.supports_extended_thinking
|
||||
},
|
||||
if fields.contains("supports_image_generation") {
|
||||
payload.supports_image_generation
|
||||
} else {
|
||||
existing.supports_image_generation
|
||||
},
|
||||
payload.is_active.unwrap_or(existing.is_active),
|
||||
payload.is_available.unwrap_or(existing.is_available),
|
||||
config,
|
||||
|
||||
@@ -9,13 +9,17 @@ use axum::body::{Body, Bytes};
|
||||
use axum::http::{self, Response};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL: &str = "Invalid token count payload";
|
||||
const CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL: &str = "请求体不能为空";
|
||||
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
|
||||
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
|
||||
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
|
||||
const OPENAI_CHAT_IMAGE_MODEL_DETAIL: &str =
|
||||
"gpt-image-2 仅支持通过 /v1/images/generations 或 /v1/images/edits 调用";
|
||||
const OPENAI_IMAGE_MODEL_DETAIL: &str = "图片接口当前仅支持模型 gpt-image-2";
|
||||
const OPENAI_IMAGE_N_DETAIL: &str = "图片接口当前仅支持 n=1";
|
||||
|
||||
pub(crate) fn ai_public_local_requires_buffered_body(
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
@@ -45,6 +49,12 @@ pub(crate) async fn maybe_build_local_ai_public_response(
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
maybe_build_local_openai_request_validation_response(request_context, request_body)
|
||||
{
|
||||
return Some(response);
|
||||
}
|
||||
|
||||
if let Some(response) =
|
||||
maybe_build_local_claude_count_tokens_response(request_context, request_body)
|
||||
{
|
||||
@@ -54,6 +64,83 @@ pub(crate) async fn maybe_build_local_ai_public_response(
|
||||
maybe_build_local_gemini_video_operations_response(state, request_context, decision).await
|
||||
}
|
||||
|
||||
fn maybe_build_local_openai_request_validation_response(
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Option<Response<Body>> {
|
||||
let decision = request_context.control_decision.as_ref()?;
|
||||
if decision.route_family.as_deref() != Some("openai")
|
||||
|| request_context.request_method != http::Method::POST
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let request_body = request_body?;
|
||||
let payload = serde_json::from_slice::<Value>(request_body).ok()?;
|
||||
|
||||
if decision.route_kind.as_deref() == Some("chat")
|
||||
&& request_context.request_path == "/v1/chat/completions"
|
||||
{
|
||||
let model = payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
if model.eq_ignore_ascii_case("gpt-image-2") {
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
OPENAI_CHAT_IMAGE_MODEL_DETAIL,
|
||||
));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() != Some("image")
|
||||
|| !matches!(
|
||||
request_context.request_path.as_str(),
|
||||
"/v1/images/generations" | "/v1/images/edits"
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(model) = payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if !model.eq_ignore_ascii_case("gpt-image-2") {
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
OPENAI_IMAGE_MODEL_DETAIL,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(n) = payload.get("n").and_then(image_request_count) {
|
||||
if n != 1 {
|
||||
return Some(build_ai_public_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
OPENAI_IMAGE_N_DETAIL,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn image_request_count(value: &Value) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_i64().and_then(|number| u64::try_from(number).ok()))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.and_then(|text| text.trim().parse::<u64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn maybe_build_local_ai_public_route_guard_response(
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
) -> Option<Response<Body>> {
|
||||
|
||||
@@ -10,13 +10,19 @@ pub(crate) fn models_api_format(request_context: &GatewayPublicRequestContext) -
|
||||
.control_decision
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.auth_endpoint_signature.as_deref())
|
||||
.filter(|signature| matches!(*signature, "openai:chat" | "claude:chat" | "gemini:chat"))
|
||||
.filter(|signature| {
|
||||
matches!(
|
||||
*signature,
|
||||
"openai:chat" | "openai:image" | "claude:chat" | "gemini:chat"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const MODELS_CROSS_FORMAT_QUERY_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"openai:image",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
@@ -26,6 +32,7 @@ const MODELS_CROSS_FORMAT_QUERY_API_FORMATS: &[&str] = &[
|
||||
pub(super) fn models_query_api_formats(api_format: &str) -> &'static [&'static str] {
|
||||
match api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" | "claude:chat" | "gemini:chat" => MODELS_CROSS_FORMAT_QUERY_API_FORMATS,
|
||||
"openai:image" => &["openai:image"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user