use axum::http::StatusCode; use platform_image::{ DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, build_vector_engine_image_request_body, create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, create_vector_engine_image_generation, download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url, vector_engine_images_generation_url, }; use serde_json::{Value, json}; use crate::{ external_api_audit::{ ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure, }, http_error::AppError, state::AppState, }; pub(crate) use platform_image::GPT_IMAGE_2_MODEL; #[cfg(test)] use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; pub(crate) type OpenAiGeneratedImages = GeneratedImages; pub(crate) type DownloadedOpenAiImage = DownloadedImage; pub(crate) type OpenAiReferenceImage = ReferenceImage; #[derive(Clone)] pub(crate) struct OpenAiImageSettings { pub base_url: String, pub api_key: String, pub request_timeout_ms: u64, pub external_api_audit_state: Option, } impl std::fmt::Debug for OpenAiImageSettings { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("OpenAiImageSettings") .field("base_url", &self.base_url) .field("api_key", &"") .field("request_timeout_ms", &self.request_timeout_ms) .field( "external_api_audit_enabled", &self.external_api_audit_state.is_some(), ) .finish() } } // 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { let base_url = state .config .vector_engine_base_url .trim() .trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "reason": "VECTOR_ENGINE_BASE_URL 未配置", })), ); } let api_key = state .config .vector_engine_api_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "reason": "VECTOR_ENGINE_API_KEY 未配置", })) })?; Ok(OpenAiImageSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), external_api_audit_state: Some(state.clone()), }) } pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { build_vector_engine_image_http_client(&settings.provider_settings()) .map_err(map_platform_image_error) } pub(crate) async fn create_openai_image_generation( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[String], failure_context: &str, ) -> Result { let result = create_vector_engine_image_generation( http_client, &settings.provider_settings(), prompt, negative_prompt, size, candidate_count, reference_images, failure_context, ) .await; map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { let result = create_vector_engine_image_edit( http_client, &settings.provider_settings(), prompt, negative_prompt, size, reference_image, failure_context, ) .await; map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit_with_references( http_client: &reqwest::Client, settings: &OpenAiImageSettings, prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { let result = create_vector_engine_image_edit_with_references( http_client, &settings.provider_settings(), prompt, negative_prompt, size, candidate_count, reference_images, failure_context, ) .await; map_platform_image_result(settings, result).await } pub(crate) async fn download_remote_image( http_client: &reqwest::Client, image_url: &str, ) -> Result { download_platform_image_remote_image(http_client, image_url) .await .map_err(map_platform_image_error) } pub(crate) fn build_openai_image_request_body( prompt: &str, negative_prompt: Option<&str>, size: &str, candidate_count: u32, reference_images: &[String], ) -> Value { build_vector_engine_image_request_body( prompt, negative_prompt, size, candidate_count, reference_images, ) } impl OpenAiImageSettings { fn provider_settings(&self) -> VectorEngineImageSettings { VectorEngineImageSettings { base_url: self.base_url.clone(), api_key: self.api_key.clone(), request_timeout_ms: self.request_timeout_ms.max(1), } } } async fn map_platform_image_result( settings: &OpenAiImageSettings, result: Result, ) -> Result { match result { Ok(value) => Ok(value), Err(error) => { record_openai_image_failure_if_configured(settings, &error).await; Err(map_platform_image_error(error)) } } } pub(crate) async fn record_openai_image_failure_if_configured( settings: &OpenAiImageSettings, error: &PlatformImageError, ) { let Some(state) = settings.external_api_audit_state.as_ref() else { return; }; let Some(draft) = build_openai_image_failure_audit_draft(error) else { return; }; record_external_api_failure(state, draft).await; } pub(crate) fn build_openai_image_failure_audit_draft( error: &PlatformImageError, ) -> Option { error .audit() .map(build_external_api_failure_draft_from_platform_image_audit) } pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { let status = match error.status_hint() { PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST, PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, PlatformImageStatusHint::BadGateway => StatusCode::BAD_GATEWAY, PlatformImageStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT, }; let mut details = json!({ "provider": error.provider(), "message": error.message(), }); match &error { PlatformImageError::InvalidConfig { .. } | PlatformImageError::InvalidRequest { .. } => {} PlatformImageError::Request { endpoint, timeout, connect, request, body, status_code, source, .. } => { details["endpoint"] = json!(endpoint); details["timeout"] = json!(timeout); details["connect"] = json!(connect); details["request"] = json!(request); details["body"] = json!(body); details["status"] = json!(status_code); details["source"] = json!(source); } PlatformImageError::Upstream { upstream_status, raw_excerpt, .. } => { details["upstreamStatus"] = json!(upstream_status); details["rawExcerpt"] = json!(raw_excerpt); } PlatformImageError::ResponseParse { raw_excerpt, .. } => { details["rawExcerpt"] = json!(raw_excerpt); } PlatformImageError::MissingImage { .. } => {} } if let Some(audit) = error.audit() { details["endpoint"] = json!(audit.endpoint); details["failureStage"] = json!(audit.failure_stage); details["statusClass"] = json!(audit.status_class); details["retryable"] = json!(audit.retryable); details["timeout"] = json!(audit.timeout); details["latencyMs"] = json!(audit.latency_ms); details["promptChars"] = json!(audit.prompt_chars); details["referenceImageCount"] = json!(audit.reference_image_count); details["imageModel"] = json!(audit.image_model); details["rawExcerpt"] = json!(audit.raw_excerpt); } AppError::from_status(status).with_details(details) } fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { vector_engine_images_generation_url(&settings.provider_settings()) } fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { vector_engine_images_edit_url(&settings.provider_settings()) } #[cfg(test)] mod tests { use super::*; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] fn gpt_image_2_generation_request_uses_create_model_without_reference_images() { let body = build_openai_image_request_body( "雾海神殿", Some("文字,水印"), "1280*720", 2, &["data:image/png;base64,abcd".to_string()], ); assert_eq!(body["model"], GPT_IMAGE_2_MODEL); assert_eq!(body["size"], "1536x1024"); assert_eq!(body["n"], 2); assert!(body.get("official_fallback").is_none()); assert!(body.get("image").is_none()); assert!(body["prompt"].as_str().unwrap_or_default().contains("避免")); } #[test] fn vector_engine_generation_url_normalizes_base_url() { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; assert_eq!( vector_engine_images_generation_url_for_test(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( vector_engine_images_generation_url_for_test(&v1_settings), "https://vector.example/v1/images/generations" ); } #[test] fn vector_engine_edit_url_normalizes_base_url() { let root_settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; assert_eq!( vector_engine_images_edit_url_for_test(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( vector_engine_images_edit_url_for_test(&v1_settings), "https://vector.example/v1/images/edits" ); } #[tokio::test] async fn vector_engine_multi_reference_edit_rejects_empty_references() { let settings = OpenAiImageSettings { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, external_api_audit_state: None, }; let http_client = reqwest::Client::new(); let result = create_openai_image_edit_with_references( &http_client, &settings, "提示词", None, "1:1", 1, &[], "测试图片编辑失败", ) .await; let error = result.expect_err("empty references should be rejected locally"); assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); assert!(error.body_text().contains("缺少参考图")); } #[test] fn reference_data_url_stays_provider_owned() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"pngbytes") ); let body = build_openai_image_request_body("提示词", None, "1:1", 1, &[source]); assert!(body.get("image").is_none()); } #[test] fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() { let audit = platform_image::PlatformImageFailureAudit { provider: VECTOR_ENGINE_PROVIDER, endpoint: "https://vector.example/v1/images/generations".to_string(), operation: "拼图 UI 背景图生成失败".to_string(), failure_stage: "upstream_status", status_code: Some(429), status_class: None, timeout: false, retryable: true, error_message: "上游限流".to_string(), error_source: None, raw_excerpt: Some("{\"error\":\"rate limited\"}".to_string()), latency_ms: Some(321), prompt_chars: Some(42), reference_image_count: Some(1), image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), }; let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft( &build_external_api_failure_draft_from_platform_image_audit(&audit), ); assert_eq!( tracking.event_key, crate::external_api_audit::EXTERNAL_API_FAILURE_EVENT_KEY ); assert_eq!(tracking.scope_id, VECTOR_ENGINE_PROVIDER); assert_eq!(tracking.metadata["provider"], VECTOR_ENGINE_PROVIDER); assert_eq!(tracking.metadata["statusCode"], 429); assert_eq!(tracking.metadata["statusClass"], "4xx"); assert_eq!(tracking.metadata["failureStage"], "upstream_status"); assert_eq!(tracking.metadata["retryable"], true); assert_eq!(tracking.metadata["promptChars"], 42); assert_eq!(tracking.metadata["referenceImageCount"], 1); assert_eq!( tracking.metadata["imageModel"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL ); } }