189 lines
6.5 KiB
Rust
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)
|
|
}
|