Files
zerogravity/src/api/util.rs

189 lines
6.5 KiB
Rust

//! 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::<serde_json::Value>(&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<ImageData> {
// data:image/png;base64,<data>
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<ImageData> {
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<ImageData> {
content
.as_array()?
.iter()
.find_map(extract_image_from_content)
}