//! Shared utilities for API handlers. use axum::{ http::StatusCode, response::{sse::Event, IntoResponse, Json}, }; use base64::Engine; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::warn; use super::types::{ErrorDetail, ErrorResponse}; use crate::proto::ImageData; pub(crate) fn err_response( status: StatusCode, message: String, error_type: &str, ) -> axum::response::Response { let body = ErrorResponse { error: ErrorDetail { message, error_type: error_type.to_string(), code: Some(status.as_u16()), param: None, }, }; (status, Json(body)).into_response() } /// Convert a MITM-captured upstream error from Google into an HTTP response. /// Forwards Google's exact error message and HTTP status code to the client. pub(crate) fn upstream_err_response( err: &crate::mitm::store::UpstreamError, ) -> axum::response::Response { let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::BAD_GATEWAY); // Map Google error status to OpenAI-style error type let error_type = match err.error_status.as_deref() { Some("INVALID_ARGUMENT") => "invalid_request_error", Some("RESOURCE_EXHAUSTED") => "rate_limit_error", Some("PERMISSION_DENIED") | Some("UNAUTHENTICATED") => "authentication_error", Some("NOT_FOUND") => "not_found_error", Some("INTERNAL") | Some("UNAVAILABLE") => "server_error", _ => "upstream_error", }; // Use Google's exact error message. Try parsed message first, then raw body. let message = if let Some(ref msg) = err.message { // Include Google's error status for context if available if let Some(ref gstatus) = err.error_status { format!("[{gstatus}] {msg}") } else { msg.clone() } } else if !err.body.is_empty() { // No parsed message — forward the raw body as-is so the client // sees exactly what Google returned (protobuf, HTML, etc.) err.body.clone() } else { format!("Google API error: HTTP {}", err.status) }; // Extract param hint from Google's error details if available let param = serde_json::from_str::(&err.body) .ok() .and_then(|v| { v["error"]["details"].as_array().and_then(|details| { details.iter().find_map(|d| { d["fieldViolations"] .as_array() .and_then(|fv| fv.first()) .and_then(|v| v["field"].as_str().map(|s| s.to_string())) }) }) }); let body = ErrorResponse { error: ErrorDetail { message, error_type: error_type.to_string(), code: Some(err.status), param, }, }; (status, Json(body)).into_response() } /// Extract the exact error message from a MITM-captured upstream error. /// Preserves Google's original message verbatim. Used by streaming handlers. pub(crate) fn upstream_error_message(err: &crate::mitm::store::UpstreamError) -> String { if let Some(ref msg) = err.message { if let Some(ref gstatus) = err.error_status { format!("[{gstatus}] {msg}") } else { msg.clone() } } else if !err.body.is_empty() { err.body.clone() } else { format!("Google API error: HTTP {}", err.status) } } /// Map Google's error status to OpenAI-compatible error type string. pub(crate) fn upstream_error_type(err: &crate::mitm::store::UpstreamError) -> &'static str { match err.error_status.as_deref() { Some("INVALID_ARGUMENT") => "invalid_request_error", Some("RESOURCE_EXHAUSTED") => "rate_limit_error", Some("PERMISSION_DENIED") | Some("UNAUTHENTICATED") => "authentication_error", Some("NOT_FOUND") => "not_found_error", Some("INTERNAL") | Some("UNAVAILABLE") => "server_error", _ => "upstream_error", } } pub(crate) fn now_unix() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } /// Default request timeout in seconds (used by serde defaults). pub(crate) fn default_timeout() -> u64 { 120 } pub(crate) fn responses_sse_event(event_type: &str, data: serde_json::Value) -> Event { Event::default() .event(event_type) .data(serde_json::to_string(&data).unwrap_or_default()) } // ─── Image extraction ──────────────────────────────────────────────────────── /// Parse a base64 data URI like `data:image/png;base64,iVBOR...` into ImageData. /// Also accepts plain URLs (returns None — we only support inline base64). pub(crate) fn parse_data_uri(url: &str) -> Option { // data:image/png;base64, let rest = url.strip_prefix("data:")?; let (header, b64) = rest.split_once(";base64,")?; let mime_type = header.to_string(); match base64::engine::general_purpose::STANDARD.decode(b64) { Ok(data) => { tracing::info!(mime = %mime_type, size = data.len(), "Decoded inline image"); Some(ImageData { mime_type, data }) } Err(e) => { warn!(error = %e, "Failed to decode base64 image data"); None } } } /// Extract an image from an OpenAI content array item. /// /// Supports: /// - Chat Completions: `{"type": "image_url", "image_url": {"url": "data:..."}}` /// - Responses API: `{"type": "input_image", "image_url": "data:..."}` or /// `{"type": "input_image", "url": "data:..."}` pub(crate) fn extract_image_from_content(item: &serde_json::Value) -> Option { let item_type = item["type"].as_str().unwrap_or(""); match item_type { // OpenAI Chat Completions format "image_url" => { let url = item["image_url"]["url"].as_str()?; parse_data_uri(url) } // OpenAI Responses API format "input_image" => { let url = item["image_url"] .as_str() .or_else(|| item["url"].as_str())?; parse_data_uri(url) } _ => None, } } /// Extract the first image from a content array (Value::Array of content parts). pub(crate) fn extract_first_image(content: &serde_json::Value) -> Option { content .as_array()? .iter() .find_map(extract_image_from_content) }