use crate::ai_serving::api::{ build_core_error_body_for_client_format, core_error_background_report_kind, core_error_default_client_api_format, core_success_background_report_kind, is_core_error_finalize_kind, maybe_compile_sync_finalize_response, normalize_provider_private_response_value as unwrap_local_finalize_response_value, LocalCoreSyncErrorKind, }; use crate::api::response::build_client_response_from_parts; use crate::control::GatewayControlDecision; use crate::usage::spawn_sync_report; use crate::{usage::GatewaySyncReportRequest, AppState, GatewayError}; use aether_usage_runtime::decode_internal_report_body_base64; use axum::body::Body; use axum::http::{Response, StatusCode}; use tracing::warn; #[derive(Clone, Debug)] struct LocalSyncErrorDetails { message: String, code: Option, kind: LocalCoreSyncErrorKind, } pub(super) fn maybe_build_local_core_error_response( trace_id: &str, decision: &GatewayControlDecision, payload: &GatewaySyncReportRequest, ) -> Result>, GatewayError> { if !is_core_error_finalize_kind(payload.report_kind.as_str()) { return Ok(None); } let Some(response_body_json) = resolve_local_core_error_response_body_json(payload)? else { return Ok(None); }; let status_source_json = resolve_local_sync_source_body_json(payload)?; if payload.status_code < 400 && !has_nested_error(&response_body_json) && !status_source_json.as_ref().is_some_and(has_nested_error) { return Ok(None); } let mut response_headers = payload.headers.clone(); response_headers.remove("content-encoding"); response_headers.remove("content-length"); response_headers.insert("content-type".to_string(), "application/json".to_string()); let body_bytes = serde_json::to_vec(&response_body_json) .map_err(|err| GatewayError::Internal(err.to_string()))?; response_headers.insert("content-length".to_string(), body_bytes.len().to_string()); Ok(Some(build_client_response_from_parts( status_source_json .as_ref() .map_or(payload.status_code, |body_json| { resolve_local_sync_error_status_code(payload.status_code, body_json) }), &response_headers, Body::from(body_bytes), trace_id, Some(decision), )?)) } fn maybe_resolve_local_sync_response_body_json( payload: &GatewaySyncReportRequest, ) -> Result, GatewayError> { if let Some(client_body_json) = payload.client_body_json.clone() { return Ok(Some(client_body_json)); } if is_core_error_finalize_kind(payload.report_kind.as_str()) { if let Some(converted) = resolve_local_core_error_response_body_json(payload)? { return Ok(Some(converted)); } } resolve_local_sync_source_body_json(payload) } fn build_local_sync_response_from_json( trace_id: &str, decision: &GatewayControlDecision, payload: &GatewaySyncReportRequest, body_json: serde_json::Value, ) -> Result, GatewayError> { let body_has_error = has_nested_error(&body_json); let status_code = if body_has_error || (payload.status_code >= 400 && is_core_error_finalize_kind(payload.report_kind.as_str())) { resolve_local_sync_error_status_code(payload.status_code, &body_json) } else { payload.status_code }; let mut response_headers = payload.headers.clone(); response_headers.remove("content-encoding"); response_headers.remove("content-length"); response_headers.insert("content-type".to_string(), "application/json".to_string()); let body_bytes = serde_json::to_vec(&body_json).map_err(|err| GatewayError::Internal(err.to_string()))?; response_headers.insert("content-length".to_string(), body_bytes.len().to_string()); build_client_response_from_parts( status_code, &response_headers, Body::from(body_bytes), trace_id, Some(decision), ) } fn build_local_sync_response_from_bytes( trace_id: &str, decision: &GatewayControlDecision, payload: &GatewaySyncReportRequest, body_bytes: Vec, ) -> Result, GatewayError> { let mut response_headers = payload.headers.clone(); response_headers.remove("content-length"); if body_bytes.is_empty() { response_headers.remove("content-encoding"); } response_headers.insert("content-length".to_string(), body_bytes.len().to_string()); build_client_response_from_parts( payload.status_code, &response_headers, Body::from(body_bytes), trace_id, Some(decision), ) } fn build_local_core_sync_finalize_fallback_response( trace_id: &str, decision: &GatewayControlDecision, payload: &GatewaySyncReportRequest, ) -> Result, GatewayError> { if let Some(body_json) = maybe_resolve_local_sync_response_body_json(payload)? { return build_local_sync_response_from_json(trace_id, decision, payload, body_json); } if let Some(body_base64) = payload.body_base64.as_ref() { let body_bytes = decode_internal_report_body_base64(body_base64).map_err(GatewayError::Internal)?; return build_local_sync_response_from_bytes(trace_id, decision, payload, body_bytes); } build_local_sync_response_from_bytes(trace_id, decision, payload, Vec::new()) } fn maybe_build_invalid_provider_success_finalize_response( trace_id: &str, decision: &GatewayControlDecision, payload: &GatewaySyncReportRequest, ) -> Result>, GatewayError> { if !local_core_sync_finalize_has_invalid_provider_success(payload)? { return Ok(None); } let client_api_format = resolve_local_sync_client_api_format(payload); let message = "Provider returned HTTP 200 but the Gemini response did not contain visible model output; refusing to finalize it as a successful response."; let body_json = build_core_error_body_for_client_format( &client_api_format, message, Some("invalid_provider_success_response"), LocalCoreSyncErrorKind::ServerError, ) .unwrap_or_else(|| { serde_json::json!({ "error": { "message": message, "type": "server_error", "code": "invalid_provider_success_response" } }) }); let mut response_headers = payload.headers.clone(); response_headers.remove("content-encoding"); response_headers.remove("content-length"); response_headers.insert("content-type".to_string(), "application/json".to_string()); let body_bytes = serde_json::to_vec(&body_json).map_err(|err| GatewayError::Internal(err.to_string()))?; response_headers.insert("content-length".to_string(), body_bytes.len().to_string()); Ok(Some(build_client_response_from_parts( StatusCode::BAD_GATEWAY.as_u16(), &response_headers, Body::from(body_bytes), trace_id, Some(decision), )?)) } fn local_core_sync_finalize_has_invalid_provider_success( payload: &GatewaySyncReportRequest, ) -> Result { if payload.status_code >= 400 || !is_core_error_finalize_kind(payload.report_kind.as_str()) { return Ok(false); } let provider_api_format = resolve_local_sync_provider_api_format(payload); if crate::ai_serving::normalize_api_format_alias(&provider_api_format) != "gemini:generate_content" { return Ok(false); } let Some(body_json) = resolve_local_sync_source_body_json(payload)? else { return Ok(false); }; if has_nested_error(&body_json) { return Ok(false); } Ok(!crate::ai_serving::gemini_generate_content_response_has_visible_output(&body_json)) } pub(crate) fn build_best_effort_local_core_error_body( payload: &GatewaySyncReportRequest, body_json: &serde_json::Value, ) -> Result, GatewayError> { let default_api_format = core_error_default_client_api_format(payload.report_kind.as_str()) .unwrap_or_default() .to_string(); let client_api_format = payload .report_context .as_ref() .and_then(|value| value.get("client_api_format")) .and_then(|value| value.as_str()) .unwrap_or(default_api_format.as_str()) .trim() .to_ascii_lowercase(); let provider_api_format = payload .report_context .as_ref() .and_then(|value| value.get("provider_api_format")) .and_then(|value| value.as_str()) .map(|value| value.trim().to_ascii_lowercase()) .unwrap_or_else(|| client_api_format.clone()); if client_api_format.is_empty() { return Ok(None); } if client_api_format == provider_api_format { return Ok(Some(body_json.clone())); } let details = extract_local_sync_error_details(payload.status_code, body_json); Ok(build_core_error_body_for_client_format( &client_api_format, &details.message, details.code.as_deref(), details.kind, )) } pub(crate) fn resolve_local_core_error_response_body_json( payload: &GatewaySyncReportRequest, ) -> Result, GatewayError> { if !is_core_error_finalize_kind(payload.report_kind.as_str()) { return Ok(None); } if let Some(client_body_json) = payload.client_body_json.clone() { return Ok(Some(client_body_json)); } if let Some(body_json) = resolve_local_sync_source_body_json(payload)? { if let Some(converted) = build_best_effort_local_core_error_body(payload, &body_json)? { return Ok(Some(converted)); } return Ok(Some(body_json)); } let Some(body_text) = decode_local_sync_body_text(payload)? else { return Ok(None); }; let client_api_format = resolve_local_sync_client_api_format(payload); if client_api_format.is_empty() { return Ok(None); } let kind = classify_local_sync_error_kind(payload.status_code, None, None, None, body_text.as_str()); Ok(build_core_error_body_for_client_format( &client_api_format, body_text.as_str(), None, kind, )) } fn resolve_local_sync_source_body_json( payload: &GatewaySyncReportRequest, ) -> Result, GatewayError> { let body_json = if let Some(body_json) = payload.body_json.clone() { body_json } else if let Some(body_base64) = payload.body_base64.as_deref() { let body_bytes = decode_internal_report_body_base64(body_base64).map_err(GatewayError::Internal)?; let stripped = strip_utf8_bom_and_ws(&body_bytes); let Ok(body_json) = serde_json::from_slice::(stripped) else { return Ok(None); }; body_json } else { return Ok(None); }; if let Some(report_context) = payload.report_context.as_ref() { if let Some(unwrapped) = unwrap_local_finalize_response_value(body_json.clone(), report_context) { return Ok(Some(unwrapped)); } } Ok(Some(body_json)) } fn decode_local_sync_body_text( payload: &GatewaySyncReportRequest, ) -> Result, GatewayError> { let Some(body_base64) = payload.body_base64.as_deref() else { return Ok(None); }; let body_bytes = decode_internal_report_body_base64(body_base64).map_err(GatewayError::Internal)?; let stripped = strip_utf8_bom_and_ws(&body_bytes); let body_text = String::from_utf8_lossy(stripped).trim().to_string(); if body_text.is_empty() { return Ok(None); } Ok(Some(body_text)) } fn resolve_local_sync_client_api_format(payload: &GatewaySyncReportRequest) -> String { let default_api_format = core_error_default_client_api_format(payload.report_kind.as_str()) .unwrap_or_default() .to_string(); payload .report_context .as_ref() .and_then(|value| value.get("client_api_format")) .and_then(|value| value.as_str()) .unwrap_or(default_api_format.as_str()) .trim() .to_ascii_lowercase() } fn resolve_local_sync_provider_api_format(payload: &GatewaySyncReportRequest) -> String { payload .report_context .as_ref() .and_then(|value| value.get("provider_api_format")) .and_then(|value| value.as_str()) .map(|value| value.trim().to_ascii_lowercase()) .unwrap_or_else(|| resolve_local_sync_client_api_format(payload)) } pub(crate) fn resolve_core_error_background_report_kind(report_kind: &str) -> Option { core_error_background_report_kind(report_kind).map(ToOwned::to_owned) } #[cfg(test)] pub(crate) fn resolve_core_success_background_report_kind(report_kind: &str) -> Option { core_success_background_report_kind(report_kind).map(ToOwned::to_owned) } pub(crate) fn resolve_local_sync_error_status_code( status_code: u16, body_json: &serde_json::Value, ) -> u16 { if (400..600).contains(&status_code) { return status_code; } let body_object = body_json.as_object(); let error_object = body_object .and_then(|object| object.get("error")) .and_then(|value| value.as_object()); let raw_code = first_non_empty_error_text(error_object, body_object, &["code"]); let raw_status = first_non_empty_error_text(error_object, body_object, &["status"]); for numeric_hint in [raw_code.as_deref(), raw_status.as_deref()] .into_iter() .flatten() { if let Ok(number) = numeric_hint.parse::() { if (400..600).contains(&number) { return number; } } } let raw_type = first_non_empty_error_text(error_object, body_object, &["type", "__type"]); let message = first_non_empty_error_text( error_object, body_object, &["message", "detail", "reason", "status", "type", "__type"], ) .unwrap_or_else(|| "HTTP 400".to_string()); let kind = classify_local_sync_error_kind( status_code, raw_type.as_deref(), raw_status.as_deref(), raw_code.as_deref(), message.as_str(), ); default_status_code_for_local_sync_error_kind(kind) } fn extract_local_sync_error_details( status_code: u16, body_json: &serde_json::Value, ) -> LocalSyncErrorDetails { let resolved_status_code = resolve_local_sync_error_status_code(status_code, body_json); let body_object = body_json.as_object(); let error_object = body_object .and_then(|object| object.get("error")) .and_then(|value| value.as_object()); let message = first_non_empty_error_text( error_object, body_object, &["message", "detail", "reason", "status", "type", "__type"], ) .unwrap_or_else(|| format!("HTTP {resolved_status_code}")); let code = first_non_empty_error_text(error_object, body_object, &["code", "status"]); let raw_type = first_non_empty_error_text(error_object, body_object, &["type", "__type"]); let raw_status = first_non_empty_error_text(error_object, body_object, &["status"]); let kind = classify_local_sync_error_kind( resolved_status_code, raw_type.as_deref(), raw_status.as_deref(), code.as_deref(), message.as_str(), ); LocalSyncErrorDetails { message, code, kind, } } fn first_non_empty_error_text( error_object: Option<&serde_json::Map>, body_object: Option<&serde_json::Map>, keys: &[&str], ) -> Option { for object in [error_object, body_object].into_iter().flatten() { for key in keys { let Some(value) = object.get(*key) else { continue; }; match value { serde_json::Value::String(text) if !text.trim().is_empty() => { return Some(text.trim().to_string()); } serde_json::Value::Number(number) => return Some(number.to_string()), _ => {} } } } None } fn classify_local_sync_error_kind( status_code: u16, raw_type: Option<&str>, raw_status: Option<&str>, raw_code: Option<&str>, message: &str, ) -> LocalCoreSyncErrorKind { let mut fingerprint = String::new(); for segment in [raw_type, raw_status, raw_code, Some(message)] { if let Some(segment) = segment.map(str::trim).filter(|value| !value.is_empty()) { if !fingerprint.is_empty() { fingerprint.push(' '); } fingerprint.push_str(&segment.to_ascii_lowercase()); } } if status_code == 429 || fingerprint.contains("rate_limit") || fingerprint.contains("rate limited") || fingerprint.contains("resource_exhausted") || fingerprint.contains("throttl") { return LocalCoreSyncErrorKind::RateLimit; } if status_code == 413 || fingerprint.contains("request_too_large") || fingerprint.contains("request too large") || fingerprint.contains("payload_too_large") || fingerprint.contains("payload too large") || fingerprint.contains("request entity too large") { return LocalCoreSyncErrorKind::RequestTooLarge; } if fingerprint.contains("contextlength") || fingerprint.contains("contentlengthexceeded") || fingerprint.contains("context window") || fingerprint.contains("context length") || fingerprint.contains("max_tokens") || (fingerprint.contains("context") && fingerprint.contains("token")) { return LocalCoreSyncErrorKind::ContextLengthExceeded; } if status_code == 401 || fingerprint.contains("unauth") || fingerprint.contains("authentication") { return LocalCoreSyncErrorKind::Authentication; } if status_code == 403 || fingerprint.contains("permission") || fingerprint.contains("forbidden") { return LocalCoreSyncErrorKind::PermissionDenied; } if status_code == 404 || fingerprint.contains("not_found") || fingerprint.contains("not found") { return LocalCoreSyncErrorKind::NotFound; } if status_code == 503 || fingerprint.contains("overload") || fingerprint.contains("unavailable") { return LocalCoreSyncErrorKind::Overloaded; } if (500..600).contains(&status_code) || raw_type.is_some_and(|value| { ["server_error", "internal_error", "api_error"] .iter() .any(|kind| value.trim().eq_ignore_ascii_case(kind)) }) { return LocalCoreSyncErrorKind::ServerError; } LocalCoreSyncErrorKind::InvalidRequest } fn default_status_code_for_local_sync_error_kind(kind: LocalCoreSyncErrorKind) -> u16 { match kind { LocalCoreSyncErrorKind::InvalidRequest | LocalCoreSyncErrorKind::ContextLengthExceeded => { 400 } LocalCoreSyncErrorKind::RequestTooLarge => 413, LocalCoreSyncErrorKind::Authentication => 401, LocalCoreSyncErrorKind::PermissionDenied => 403, LocalCoreSyncErrorKind::NotFound => 404, LocalCoreSyncErrorKind::RateLimit => 429, LocalCoreSyncErrorKind::Overloaded => 503, LocalCoreSyncErrorKind::ServerError => 500, } } pub(crate) fn strip_utf8_bom_and_ws(mut body: &[u8]) -> &[u8] { loop { while let Some(first) = body.first() { if first.is_ascii_whitespace() { body = &body[1..]; } else { break; } } if body.starts_with(&[0xEF, 0xBB, 0xBF]) { body = &body[3..]; } else { break; } } body } pub(crate) fn has_nested_error(value: &serde_json::Value) -> bool { let Some(object) = value.as_object() else { return false; }; if object.get("error").is_some_and(|error| !error.is_null()) { return true; } if object .get("type") .and_then(|value| value.as_str()) .is_some_and(|value| value == "error") { return true; } object .get("chunks") .and_then(|value| value.as_array()) .is_some_and(|chunks| { chunks.iter().any(|chunk| { chunk.as_object().is_some_and(|chunk_object| { chunk_object .get("error") .is_some_and(|error| !error.is_null()) || chunk_object .get("type") .and_then(|value| value.as_str()) .is_some_and(|value| value == "error") }) }) }) } pub(crate) async fn submit_local_core_error_or_sync_finalize( state: &AppState, trace_id: &str, decision: &GatewayControlDecision, payload: GatewaySyncReportRequest, ) -> Result, GatewayError> { let response = if let Some(response) = maybe_compile_sync_finalize_response(trace_id, decision, &payload)? { response } else if let Some(response) = maybe_build_invalid_provider_success_finalize_response(trace_id, decision, &payload)? { response } else if let Some(response) = maybe_build_local_core_error_response(trace_id, decision, &payload)? { response } else { warn!( event_name = "local_core_finalize_fallback_raw_response_body", log_type = "event", trace_id = %trace_id, report_kind = %payload.report_kind, status_code = payload.status_code, client_api_format = payload.report_context.as_ref().and_then(|value| value.get("client_api_format")).and_then(|value| value.as_str()).unwrap_or(""), provider_api_format = payload.report_context.as_ref().and_then(|value| value.get("provider_api_format")).and_then(|value| value.as_str()).unwrap_or(""), envelope_name = payload.report_context.as_ref().and_then(|value| value.get("envelope_name")).and_then(|value| value.as_str()).unwrap_or(""), needs_conversion = payload.report_context.as_ref().and_then(|value| value.get("needs_conversion")).and_then(|value| value.as_bool()).unwrap_or(false), "gateway local core finalize fell back to raw response body" ); build_local_core_sync_finalize_fallback_response(trace_id, decision, &payload)? }; let response_status = response.status(); if response_status.is_success() { if let Some(success_report_kind) = core_success_background_report_kind(payload.report_kind.as_str()) { let mut report_payload = payload.clone(); report_payload.report_kind = success_report_kind.to_string(); report_payload.status_code = response_status.as_u16(); spawn_sync_report(state.clone(), report_payload); } else { warn!( event_name = "local_core_finalize_missing_success_report_mapping", log_type = "event", trace_id = %trace_id, report_kind = %payload.report_kind, "gateway built local core finalize success response without background success report mapping" ); } } else if let Some(error_report_kind) = resolve_core_error_background_report_kind(payload.report_kind.as_str()) { let mut report_payload = payload.clone(); report_payload.report_kind = error_report_kind; report_payload.status_code = response_status.as_u16(); spawn_sync_report(state.clone(), report_payload); } else { warn!( event_name = "local_core_finalize_missing_error_report_mapping", log_type = "event", trace_id = %trace_id, report_kind = %payload.report_kind, "gateway built local core finalize response without background error report mapping" ); } Ok(response) } #[cfg(test)] mod tests { #[test] fn success_http_status_does_not_misclassify_explicit_server_errors_as_bad_requests() { for error_type in ["server_error", "internal_error", "api_error"] { let body = serde_json::json!({ "error": { "type": error_type, "message": "failed" } }); assert_eq!(super::resolve_local_sync_error_status_code(200, &body), 500); } } use axum::body::to_bytes; use serde_json::json; use super::{maybe_build_local_core_error_response, submit_local_core_error_or_sync_finalize}; use crate::control::GatewayControlDecision; use crate::usage::GatewaySyncReportRequest; use crate::AppState; fn test_decision() -> GatewayControlDecision { GatewayControlDecision::synthetic( "/v1/chat/completions", Some("ai_public".to_string()), Some("openai".to_string()), Some("chat".to_string()), Some("openai:chat".to_string()), ) .with_execution_runtime_candidate(true) } fn core_finalize_payload( report_kind: &str, client_api_format: &str, provider_api_format: &str, status_code: u16, body_json: serde_json::Value, ) -> GatewaySyncReportRequest { GatewaySyncReportRequest { trace_id: "trace-core-error-status-123".to_string(), report_kind: report_kind.to_string(), report_context: Some(json!({ "client_api_format": client_api_format, "provider_api_format": provider_api_format, })), status_code, headers: Default::default(), body_json: Some(body_json), client_body_json: None, body_base64: None, telemetry: None, } } #[tokio::test] async fn maybe_build_local_core_error_response_infers_status_from_semantic_error_type() { let payload = core_finalize_payload( "openai_chat_sync_finalize", "openai:chat", "claude:messages", 200, json!({ "type": "error", "error": { "type": "rate_limit_error", "message": "slow down" } }), ); let response = maybe_build_local_core_error_response( "trace-sync-status-type", &test_decision(), &payload, ) .expect("response build should not error") .expect("response should exist"); assert_eq!(response.status(), http::StatusCode::TOO_MANY_REQUESTS); assert_eq!( serde_json::from_slice::( &to_bytes(response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"), json!({ "error": { "message": "slow down", "type": "rate_limit_error" } }) ); } #[tokio::test] async fn maybe_build_local_core_error_response_infers_status_from_gemini_status_text() { let payload = core_finalize_payload( "gemini_chat_sync_finalize", "gemini:generate_content", "gemini:generate_content", 200, json!({ "error": { "message": "quota reached", "status": "RESOURCE_EXHAUSTED" } }), ); let response = maybe_build_local_core_error_response( "trace-sync-status-gemini", &test_decision(), &payload, ) .expect("response build should not error") .expect("response should exist"); assert_eq!(response.status(), http::StatusCode::TOO_MANY_REQUESTS); assert_eq!( serde_json::from_slice::( &to_bytes(response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"), json!({ "error": { "message": "quota reached", "status": "RESOURCE_EXHAUSTED" } }) ); } #[tokio::test] async fn local_core_error_maps_request_too_large_without_changing_openai_shape() { let claude_payload = core_finalize_payload( "claude_chat_sync_finalize", "claude:messages", "openai:chat", 413, json!({ "error": { "type": "invalid_request_error", "message": "request body is too large" } }), ); let claude_response = maybe_build_local_core_error_response( "trace-sync-claude-too-large", &test_decision(), &claude_payload, ) .expect("response build should not error") .expect("response should exist"); assert_eq!( claude_response.status(), http::StatusCode::PAYLOAD_TOO_LARGE ); let claude_body: serde_json::Value = serde_json::from_slice( &to_bytes(claude_response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"); assert_eq!(claude_body["type"], "error"); assert_eq!(claude_body["error"]["type"], "request_too_large"); let openai_payload = core_finalize_payload( "openai_chat_sync_finalize", "openai:chat", "claude:messages", 200, json!({ "type": "error", "error": { "type": "request_too_large", "message": "request body is too large" } }), ); let openai_response = maybe_build_local_core_error_response( "trace-sync-openai-too-large", &test_decision(), &openai_payload, ) .expect("response build should not error") .expect("response should exist"); assert_eq!( openai_response.status(), http::StatusCode::PAYLOAD_TOO_LARGE ); let openai_body: serde_json::Value = serde_json::from_slice( &to_bytes(openai_response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"); assert_eq!( openai_body["error"]["type"], "context_length_exceeded", "OpenAI compatibility shape should remain unchanged" ); } #[tokio::test] async fn local_core_sync_finalize_rejects_gemini_http_200_without_visible_output() { let mut payload = core_finalize_payload( "openai_chat_sync_finalize", "openai:chat", "gemini:generate_content", 200, json!({ "candidates": [{ "content": {"role": "model"}, "finishReason": "MAX_TOKENS" }], "usageMetadata": { "promptTokenCount": 8, "candidatesTokenCount": 1, "thoughtsTokenCount": 25, "totalTokenCount": 34 }, "modelVersion": "gemini-3-flash-preview", "responseId": "resp-empty" }), ); payload.report_context = Some(json!({ "client_api_format": "openai:chat", "provider_api_format": "gemini:generate_content", "needs_conversion": true, "has_envelope": false })); let state = AppState::new().expect("state should build"); let response = submit_local_core_error_or_sync_finalize( &state, "trace-invalid-gemini-200", &test_decision(), payload, ) .await .expect("response should build"); assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY); let body: serde_json::Value = serde_json::from_slice( &to_bytes(response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"); let message = body["error"]["message"] .as_str() .expect("error message should exist"); assert!( message.contains("visible model output"), "unexpected message: {message}" ); } #[tokio::test] async fn submit_local_core_finalize_keeps_http_200_for_success_image_body() { let payload = core_finalize_payload( "openai_image_sync_finalize", "openai:image", "openai:image", 200, json!({ "created": 1779273523, "data": [{ "b64_json": "aGVsbG8=" }] }), ); let state = AppState::new().expect("state should build"); let response = submit_local_core_error_or_sync_finalize( &state, "trace-image-success-200", &test_decision(), payload, ) .await .expect("response should build"); assert_eq!(response.status(), http::StatusCode::OK); let body: serde_json::Value = serde_json::from_slice( &to_bytes(response.into_body(), usize::MAX) .await .expect("body should read"), ) .expect("body should decode"); assert_eq!(body["data"][0]["b64_json"], "aGVsbG8="); } }