mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10: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:
@@ -1,6 +1,7 @@
|
||||
use aether_data_contracts::repository::video_tasks::VideoTaskLookupKey;
|
||||
use aether_usage_runtime::build_locally_actionable_report_context_from_video_task;
|
||||
use serde_json::Value;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::request_candidate_runtime::resolve_locally_actionable_request_candidate_report_context;
|
||||
use crate::video_tasks::{resolve_video_task_report_lookup, VideoTaskReportLookup};
|
||||
@@ -8,6 +9,9 @@ use crate::AppState;
|
||||
|
||||
pub(crate) use aether_usage_runtime::report_context_is_locally_actionable;
|
||||
|
||||
const REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_ATTEMPTS: usize = 5;
|
||||
const REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_DELAY_MS: u64 = 50;
|
||||
|
||||
pub(crate) async fn resolve_locally_actionable_report_context(
|
||||
state: &AppState,
|
||||
report_context: Option<&Value>,
|
||||
@@ -18,7 +22,8 @@ pub(crate) async fn resolve_locally_actionable_report_context(
|
||||
}
|
||||
|
||||
if let Some(resolved) =
|
||||
resolve_locally_actionable_request_candidate_report_context(state, &context).await
|
||||
resolve_locally_actionable_request_candidate_report_context_with_retry(state, &context)
|
||||
.await
|
||||
{
|
||||
return Some(resolved);
|
||||
}
|
||||
@@ -28,7 +33,8 @@ pub(crate) async fn resolve_locally_actionable_report_context(
|
||||
.unwrap_or(context);
|
||||
|
||||
if let Some(resolved) =
|
||||
resolve_locally_actionable_request_candidate_report_context(state, &context).await
|
||||
resolve_locally_actionable_request_candidate_report_context_with_retry(state, &context)
|
||||
.await
|
||||
{
|
||||
return Some(resolved);
|
||||
}
|
||||
@@ -36,6 +42,38 @@ pub(crate) async fn resolve_locally_actionable_report_context(
|
||||
report_context_is_locally_actionable(Some(&context)).then_some(context)
|
||||
}
|
||||
|
||||
async fn resolve_locally_actionable_request_candidate_report_context_with_retry(
|
||||
state: &AppState,
|
||||
context: &Value,
|
||||
) -> Option<Value> {
|
||||
if context
|
||||
.get("request_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
for attempt in 0..=REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_ATTEMPTS {
|
||||
if let Some(resolved) =
|
||||
resolve_locally_actionable_request_candidate_report_context(state, context).await
|
||||
{
|
||||
return Some(resolved);
|
||||
}
|
||||
|
||||
if attempt < REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_ATTEMPTS {
|
||||
sleep(Duration::from_millis(
|
||||
REQUEST_CANDIDATE_REPORT_CONTEXT_RETRY_DELAY_MS,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn resolve_locally_actionable_report_context_from_video_task(
|
||||
state: &AppState,
|
||||
context: &Value,
|
||||
|
||||
@@ -40,6 +40,25 @@ fn log_local_report_handled(
|
||||
);
|
||||
}
|
||||
|
||||
fn log_local_report_effect_only(
|
||||
trace_id: &str,
|
||||
report_kind: &str,
|
||||
report_scope: &'static str,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
) {
|
||||
debug!(
|
||||
event_name = "execution_report_effect_handled_locally",
|
||||
log_type = "debug",
|
||||
debug_context = "redacted",
|
||||
trace_id = %trace_id,
|
||||
report_scope,
|
||||
report_kind = %report_kind,
|
||||
report_request_id = %short_request_id(report_request_id(report_context)),
|
||||
has_report_context = report_context.is_some(),
|
||||
"gateway handled execution report locally without actionable request-candidate context"
|
||||
);
|
||||
}
|
||||
|
||||
fn log_dropped_report(
|
||||
trace_id: &str,
|
||||
report_kind: &str,
|
||||
@@ -98,6 +117,19 @@ pub(crate) async fn submit_sync_report(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if payload.report_context.is_some()
|
||||
&& is_local_ai_sync_report_kind(payload.report_kind.as_str())
|
||||
{
|
||||
handle_local_sync_report(state, &payload).await;
|
||||
log_local_report_effect_only(
|
||||
payload.trace_id.as_str(),
|
||||
&payload.report_kind,
|
||||
"sync",
|
||||
payload.report_context.as_ref(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log_dropped_report(
|
||||
payload.trace_id.as_str(),
|
||||
&payload.report_kind,
|
||||
@@ -165,6 +197,19 @@ pub(crate) async fn submit_stream_report(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if payload.report_context.is_some()
|
||||
&& is_local_ai_stream_report_kind(payload.report_kind.as_str())
|
||||
{
|
||||
handle_local_stream_report(state, &payload).await;
|
||||
log_local_report_effect_only(
|
||||
payload.trace_id.as_str(),
|
||||
&payload.report_kind,
|
||||
"stream",
|
||||
payload.report_context.as_ref(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log_dropped_report(
|
||||
payload.trace_id.as_str(),
|
||||
&payload.report_kind,
|
||||
@@ -562,6 +607,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_handles_openai_image_success_locally_when_unique_candidate_exists()
|
||||
{
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||
sample_request_candidate(
|
||||
"cand-reporting-image-sync-123",
|
||||
"req-reporting-image-sync-123",
|
||||
),
|
||||
]));
|
||||
let state = build_test_state(Arc::clone(&repository));
|
||||
|
||||
submit_sync_report(
|
||||
&state,
|
||||
GatewaySyncReportRequest {
|
||||
trace_id: "trace-reporting-image-sync-123".to_string(),
|
||||
report_kind: "openai_image_sync_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"request_id": "req-reporting-image-sync-123",
|
||||
"client_api_format": "openai:image"
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body_json: Some(json!({
|
||||
"created": 1776855978,
|
||||
"data": [{
|
||||
"b64_json": "aGVsbG8="
|
||||
}]
|
||||
})),
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
telemetry: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("image sync report should stay local");
|
||||
|
||||
let stored = repository
|
||||
.list_by_request_id("req-reporting-image-sync-123")
|
||||
.await
|
||||
.expect("request candidates should list");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, "cand-reporting-image-sync-123");
|
||||
assert_eq!(stored[0].status, RequestCandidateStatus::Success);
|
||||
assert_eq!(stored[0].status_code, Some(200));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_treats_null_error_field_as_success() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||
@@ -837,6 +928,57 @@ mod tests {
|
||||
assert_eq!(stored.mime_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_stores_gemini_file_mapping_without_actionable_candidate_context() {
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let gemini_file_mapping_repository =
|
||||
Arc::new(InMemoryGeminiFileMappingRepository::default());
|
||||
let state = build_gemini_file_mapping_test_state(
|
||||
Arc::clone(&request_candidate_repository),
|
||||
Arc::clone(&gemini_file_mapping_repository),
|
||||
);
|
||||
|
||||
submit_sync_report(
|
||||
&state,
|
||||
GatewaySyncReportRequest {
|
||||
trace_id: "trace-gemini-files-store-no-candidate-123".to_string(),
|
||||
report_kind: "gemini_files_store_mapping".to_string(),
|
||||
report_context: Some(json!({
|
||||
"request_id": "req-gemini-files-store-no-candidate-123",
|
||||
"file_key_id": "key-reporting-tests-123",
|
||||
"user_id": "user-reporting-tests-123",
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::from([(
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
body_json: Some(json!({
|
||||
"file": {
|
||||
"name": "fallback123",
|
||||
"displayName": "fallback-image",
|
||||
"mimeType": "image/png"
|
||||
}
|
||||
})),
|
||||
client_body_json: None,
|
||||
body_base64: None,
|
||||
telemetry: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("gemini files mapping fallback report should stay local");
|
||||
|
||||
let stored = gemini_file_mapping_repository
|
||||
.find_by_file_name("files/fallback123")
|
||||
.await
|
||||
.expect("gemini file mapping should read")
|
||||
.expect("gemini file mapping should exist");
|
||||
assert_eq!(stored.key_id, "key-reporting-tests-123");
|
||||
assert_eq!(stored.user_id.as_deref(), Some("user-reporting-tests-123"));
|
||||
assert_eq!(stored.display_name.as_deref(), Some("fallback-image"));
|
||||
assert_eq!(stored.mime_type.as_deref(), Some("image/png"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_deletes_gemini_file_mapping_locally_on_success() {
|
||||
let request_candidate_repository =
|
||||
|
||||
Reference in New Issue
Block a user