mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
1654 lines
56 KiB
Rust
1654 lines
56 KiB
Rust
use crate::async_task::CancelVideoTaskError;
|
|
use crate::control::GatewayControlDecision;
|
|
use crate::control::GatewayPublicRequestContext;
|
|
use crate::image_capabilities::{
|
|
openai_image_gateway_max_generation_count, openai_image_gateway_max_generation_count_for_model,
|
|
};
|
|
use crate::{AppState, GatewayError};
|
|
use aether_data_contracts::repository::video_tasks::{
|
|
StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus,
|
|
};
|
|
use axum::body::{Body, Bytes};
|
|
use axum::http::{self, Response};
|
|
use axum::response::IntoResponse;
|
|
use axum::Json;
|
|
use serde_json::{json, Value};
|
|
|
|
const CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL: &str = "Invalid token count payload";
|
|
const CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL: &str = "请求体不能为空";
|
|
const GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL: &str = "Video task not found";
|
|
const AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL: &str = "Method not allowed";
|
|
const AI_PUBLIC_UNAUTHORIZED_DETAIL: &str = "Unauthorized";
|
|
const OPENAI_IMAGE_PROMPT_DETAIL: &str = "图片生成/编辑请求缺少 prompt";
|
|
const OPENAI_IMAGE_EDIT_INPUT_DETAIL: &str = "图片编辑请求至少需要 1 张输入图片";
|
|
const OPENAI_IMAGE_PARTIAL_IMAGES_DETAIL: &str =
|
|
"partial_images 仅支持 0-3,且必须配合 stream=true";
|
|
const OPENAI_IMAGE_STYLE_DETAIL: &str = "当前 Codex 图片反代暂不支持 style 参数";
|
|
const OPENAI_IMAGE_RESPONSE_FORMAT_DETAIL: &str = "response_format 仅支持 url 或 b64_json";
|
|
const OPENAI_IMAGE_OUTPUT_FORMAT_DETAIL: &str = "output_format 仅支持 png、jpeg 或 webp";
|
|
const OPENAI_IMAGE_QUALITY_DETAIL: &str = "quality 仅支持 low、medium、high、standard 或 hd";
|
|
const OPENAI_IMAGE_BACKGROUND_DETAIL: &str = "background 仅支持 auto、opaque 或 transparent";
|
|
const OPENAI_IMAGE_MODERATION_DETAIL: &str = "moderation 仅支持 auto 或 low";
|
|
const OPENAI_IMAGE_INPUT_FIDELITY_DETAIL: &str = "input_fidelity 仅支持 low 或 high";
|
|
const OPENAI_IMAGE_OUTPUT_COMPRESSION_DETAIL: &str = "output_compression 必须是 0-100 的整数";
|
|
const OPENAI_IMAGE_INVALID_JSON_DETAIL: &str = "图片接口 JSON 请求体无效";
|
|
const OPENAI_IMAGE_INVALID_MULTIPART_DETAIL: &str = "图片接口 multipart/form-data 请求体无效";
|
|
const OPENAI_EMBEDDING_CONTENT_TYPE_DETAIL: &str =
|
|
"Embedding request content-type must be application/json";
|
|
const OPENAI_EMBEDDING_INVALID_JSON_DETAIL: &str = "Embedding request JSON body is invalid";
|
|
const OPENAI_EMBEDDING_MODEL_REQUIRED_DETAIL: &str = "Embedding request model is required";
|
|
const OPENAI_EMBEDDING_INPUT_REQUIRED_DETAIL: &str = "Embedding request input is required";
|
|
const OPENAI_EMBEDDING_CHAT_PAYLOAD_DETAIL: &str =
|
|
"Embedding request must use input, not chat messages";
|
|
const OPENAI_EMBEDDING_STREAM_UNSUPPORTED_DETAIL: &str =
|
|
"Embedding requests do not support streaming";
|
|
const OPENAI_RERANK_CONTENT_TYPE_DETAIL: &str =
|
|
"Rerank request content-type must be application/json";
|
|
const OPENAI_RERANK_INVALID_JSON_DETAIL: &str = "Rerank request JSON body is invalid";
|
|
const OPENAI_RERANK_MODEL_REQUIRED_DETAIL: &str = "Rerank request model is required";
|
|
const OPENAI_RERANK_QUERY_REQUIRED_DETAIL: &str = "Rerank request query is required";
|
|
const OPENAI_RERANK_DOCUMENTS_REQUIRED_DETAIL: &str = "Rerank request documents are required";
|
|
const OPENAI_RERANK_TOP_N_DETAIL: &str = "Rerank request top_n must be a positive integer";
|
|
const OPENAI_RERANK_CHAT_PAYLOAD_DETAIL: &str =
|
|
"Rerank request must use query/documents, not chat messages";
|
|
const OPENAI_RERANK_STREAM_UNSUPPORTED_DETAIL: &str = "Rerank requests do not support streaming";
|
|
const ANTIGRAVITY_USER_SETTINGS_MISSING_BODY_DETAIL: &str =
|
|
"Antigravity setUserSettings request body is required";
|
|
const ANTIGRAVITY_USER_SETTINGS_INVALID_JSON_DETAIL: &str =
|
|
"Antigravity setUserSettings request JSON body is invalid";
|
|
const ANTIGRAVITY_USER_SETTINGS_INVALID_DETAIL: &str =
|
|
"Antigravity setUserSettings request must include object userSettings";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum OpenAiImageOperation {
|
|
Generate,
|
|
Edit,
|
|
}
|
|
|
|
impl OpenAiImageOperation {
|
|
fn from_path(path: &str) -> Option<Self> {
|
|
match path {
|
|
"/v1/images/generations" => Some(Self::Generate),
|
|
"/v1/images/edits" => Some(Self::Edit),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct OpenAiImageValidationInput {
|
|
model: Option<String>,
|
|
prompt: Option<String>,
|
|
image_count: usize,
|
|
n: Option<u64>,
|
|
stream: bool,
|
|
partial_images: Option<u64>,
|
|
response_format: Option<String>,
|
|
output_format: Option<String>,
|
|
quality: Option<String>,
|
|
background: Option<String>,
|
|
moderation: Option<String>,
|
|
input_fidelity: Option<String>,
|
|
output_compression: Option<u64>,
|
|
style_present: bool,
|
|
}
|
|
|
|
pub(crate) fn ai_public_local_requires_buffered_body(
|
|
request_context: &GatewayPublicRequestContext,
|
|
) -> bool {
|
|
request_context
|
|
.control_decision
|
|
.as_ref()
|
|
.is_some_and(|decision| {
|
|
decision.route_class.as_deref() == Some("ai_public")
|
|
&& request_context.request_method == http::Method::POST
|
|
&& ((decision.route_family.as_deref() == Some("claude")
|
|
&& decision.route_kind.as_deref() == Some("count_tokens"))
|
|
|| (decision.route_family.as_deref() == Some("openai")
|
|
&& decision.route_kind.as_deref() == Some("embedding")
|
|
&& request_context.request_path == "/v1/embeddings")
|
|
|| (decision.route_family.as_deref() == Some("openai")
|
|
&& decision.route_kind.as_deref() == Some("rerank")
|
|
&& request_context.request_path == "/v1/rerank")
|
|
|| (decision.route_family.as_deref() == Some("antigravity")
|
|
&& decision.route_kind.as_deref() != Some("stream_generate_content")))
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn maybe_build_local_ai_public_response(
|
|
state: &AppState,
|
|
request_context: &GatewayPublicRequestContext,
|
|
request_body: Option<&Bytes>,
|
|
) -> Option<Response<Body>> {
|
|
if let Some(response) = maybe_build_local_ai_public_route_guard_response(request_context) {
|
|
return Some(response);
|
|
}
|
|
|
|
let decision = request_context.control_decision.as_ref()?;
|
|
if decision.route_class.as_deref() != Some("ai_public") {
|
|
return None;
|
|
}
|
|
|
|
if let Some(response) =
|
|
maybe_build_local_openai_request_validation_response(request_context, request_body)
|
|
{
|
|
return Some(response);
|
|
}
|
|
|
|
if let Some(response) =
|
|
maybe_build_local_claude_count_tokens_response(request_context, request_body)
|
|
{
|
|
return Some(response);
|
|
}
|
|
|
|
if let Some(response) =
|
|
maybe_build_local_antigravity_v1internal_response(request_context, request_body)
|
|
{
|
|
return Some(response);
|
|
}
|
|
|
|
maybe_build_local_gemini_video_operations_response(state, request_context, decision).await
|
|
}
|
|
|
|
fn maybe_build_local_openai_request_validation_response(
|
|
request_context: &GatewayPublicRequestContext,
|
|
request_body: Option<&Bytes>,
|
|
) -> Option<Response<Body>> {
|
|
let decision = request_context.control_decision.as_ref()?;
|
|
if decision.route_family.as_deref() != Some("openai")
|
|
|| request_context.request_method != http::Method::POST
|
|
{
|
|
return None;
|
|
}
|
|
|
|
if decision.route_kind.as_deref() == Some("chat")
|
|
&& request_context.request_path == "/v1/chat/completions"
|
|
{
|
|
return None;
|
|
}
|
|
|
|
if decision.route_kind.as_deref() == Some("embedding")
|
|
&& request_context.request_path == "/v1/embeddings"
|
|
{
|
|
let Some(request_body) = request_body else {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_EMBEDDING_INVALID_JSON_DETAIL,
|
|
));
|
|
};
|
|
if let Err(detail) = validate_openai_embedding_request(
|
|
request_context.request_content_type.as_deref(),
|
|
request_body,
|
|
) {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
detail,
|
|
));
|
|
}
|
|
return None;
|
|
}
|
|
|
|
if decision.route_kind.as_deref() == Some("rerank")
|
|
&& request_context.request_path == "/v1/rerank"
|
|
{
|
|
let Some(request_body) = request_body else {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_RERANK_INVALID_JSON_DETAIL,
|
|
));
|
|
};
|
|
if let Err(detail) = validate_openai_rerank_request(
|
|
request_context.request_content_type.as_deref(),
|
|
request_body,
|
|
) {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
detail,
|
|
));
|
|
}
|
|
return None;
|
|
}
|
|
|
|
let request_body = request_body?;
|
|
|
|
if decision.route_kind.as_deref() != Some("image")
|
|
|| !matches!(
|
|
request_context.request_path.as_str(),
|
|
"/v1/images/generations" | "/v1/images/edits"
|
|
)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let Some(operation) = OpenAiImageOperation::from_path(&request_context.request_path) else {
|
|
return None;
|
|
};
|
|
let validation = match parse_openai_image_validation_input(
|
|
operation,
|
|
request_context.request_content_type.as_deref(),
|
|
request_body,
|
|
) {
|
|
Ok(validation) => validation,
|
|
Err(detail) => {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
detail,
|
|
));
|
|
}
|
|
};
|
|
|
|
match operation {
|
|
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit
|
|
if validation.prompt.is_none() =>
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_PROMPT_DETAIL,
|
|
));
|
|
}
|
|
OpenAiImageOperation::Edit if validation.image_count == 0 => {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_EDIT_INPUT_DETAIL,
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if let Some(detail) = validate_openai_image_n(&validation) {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
detail,
|
|
));
|
|
}
|
|
|
|
if validation.partial_images.is_some_and(|value| value > 3)
|
|
|| (validation.partial_images.is_some() && !validation.stream)
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_PARTIAL_IMAGES_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation.style_present {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_STYLE_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.response_format
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "url" | "b64_json"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_RESPONSE_FORMAT_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.output_format
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "png" | "jpeg" | "jpg" | "webp"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_OUTPUT_FORMAT_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.quality
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "low" | "medium" | "high" | "standard" | "hd"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_QUALITY_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.background
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "auto" | "opaque" | "transparent"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_BACKGROUND_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.moderation
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "auto" | "low"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_MODERATION_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.input_fidelity
|
|
.as_deref()
|
|
.is_some_and(|value| !matches!(value, "low" | "high"))
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_INPUT_FIDELITY_DETAIL,
|
|
));
|
|
}
|
|
|
|
if validation
|
|
.output_compression
|
|
.is_some_and(|value| value > 100)
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
OPENAI_IMAGE_OUTPUT_COMPRESSION_DETAIL,
|
|
));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn openai_image_n_detail(max_generation_count: u64) -> String {
|
|
if max_generation_count >= openai_image_gateway_max_generation_count() {
|
|
format!("当前图片反代仅支持 n=1..{max_generation_count}")
|
|
} else {
|
|
format!("当前图片模型仅支持 n=1..{max_generation_count}")
|
|
}
|
|
}
|
|
|
|
fn validate_openai_image_n(validation: &OpenAiImageValidationInput) -> Option<String> {
|
|
let max_generation_count =
|
|
openai_image_gateway_max_generation_count_for_model(validation.model.as_deref());
|
|
validation
|
|
.n
|
|
.is_some_and(|value| value == 0 || value > max_generation_count)
|
|
.then(|| openai_image_n_detail(max_generation_count))
|
|
}
|
|
|
|
fn validate_openai_embedding_request(
|
|
content_type: Option<&str>,
|
|
request_body: &Bytes,
|
|
) -> Result<(), &'static str> {
|
|
if !content_type
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase()
|
|
.contains("application/json")
|
|
{
|
|
return Err(OPENAI_EMBEDDING_CONTENT_TYPE_DETAIL);
|
|
}
|
|
if request_body.is_empty() {
|
|
return Err(OPENAI_EMBEDDING_INVALID_JSON_DETAIL);
|
|
}
|
|
let payload = serde_json::from_slice::<Value>(request_body)
|
|
.map_err(|_| OPENAI_EMBEDDING_INVALID_JSON_DETAIL)?;
|
|
let object = payload
|
|
.as_object()
|
|
.ok_or(OPENAI_EMBEDDING_INVALID_JSON_DETAIL)?;
|
|
if object.contains_key("messages") {
|
|
return Err(OPENAI_EMBEDDING_CHAT_PAYLOAD_DETAIL);
|
|
}
|
|
if object
|
|
.get("stream")
|
|
.and_then(value_as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(OPENAI_EMBEDDING_STREAM_UNSUPPORTED_DETAIL);
|
|
}
|
|
if object
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_none()
|
|
{
|
|
return Err(OPENAI_EMBEDDING_MODEL_REQUIRED_DETAIL);
|
|
}
|
|
let Some(input) = object.get("input") else {
|
|
return Err(OPENAI_EMBEDDING_INPUT_REQUIRED_DETAIL);
|
|
};
|
|
if !embedding_input_is_non_empty(input) {
|
|
return Err(OPENAI_EMBEDDING_INPUT_REQUIRED_DETAIL);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_openai_rerank_request(
|
|
content_type: Option<&str>,
|
|
request_body: &Bytes,
|
|
) -> Result<(), &'static str> {
|
|
if !content_type
|
|
.unwrap_or_default()
|
|
.to_ascii_lowercase()
|
|
.contains("application/json")
|
|
{
|
|
return Err(OPENAI_RERANK_CONTENT_TYPE_DETAIL);
|
|
}
|
|
if request_body.is_empty() {
|
|
return Err(OPENAI_RERANK_INVALID_JSON_DETAIL);
|
|
}
|
|
let payload = serde_json::from_slice::<Value>(request_body)
|
|
.map_err(|_| OPENAI_RERANK_INVALID_JSON_DETAIL)?;
|
|
let object = payload
|
|
.as_object()
|
|
.ok_or(OPENAI_RERANK_INVALID_JSON_DETAIL)?;
|
|
if object.contains_key("messages") {
|
|
return Err(OPENAI_RERANK_CHAT_PAYLOAD_DETAIL);
|
|
}
|
|
if object
|
|
.get("stream")
|
|
.and_then(value_as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(OPENAI_RERANK_STREAM_UNSUPPORTED_DETAIL);
|
|
}
|
|
if object
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_none()
|
|
{
|
|
return Err(OPENAI_RERANK_MODEL_REQUIRED_DETAIL);
|
|
}
|
|
if object
|
|
.get("query")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.is_none()
|
|
{
|
|
return Err(OPENAI_RERANK_QUERY_REQUIRED_DETAIL);
|
|
}
|
|
let Some(documents) = object.get("documents").and_then(Value::as_array) else {
|
|
return Err(OPENAI_RERANK_DOCUMENTS_REQUIRED_DETAIL);
|
|
};
|
|
if documents.is_empty() || documents.iter().any(rerank_document_is_empty) {
|
|
return Err(OPENAI_RERANK_DOCUMENTS_REQUIRED_DETAIL);
|
|
}
|
|
if object
|
|
.get("top_n")
|
|
.or_else(|| object.get("topN"))
|
|
.is_some_and(|value| !positive_json_integer(value))
|
|
{
|
|
return Err(OPENAI_RERANK_TOP_N_DETAIL);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn rerank_document_is_empty(value: &Value) -> bool {
|
|
match value {
|
|
Value::String(text) => text.trim().is_empty(),
|
|
Value::Object(object) => object
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|text| text.trim().is_empty()),
|
|
Value::Null => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn positive_json_integer(value: &Value) -> bool {
|
|
value.as_u64().is_some_and(|number| number > 0)
|
|
|| value.as_i64().is_some_and(|number| number > 0)
|
|
|| value
|
|
.as_str()
|
|
.and_then(|text| text.trim().parse::<u64>().ok())
|
|
.is_some_and(|number| number > 0)
|
|
}
|
|
|
|
fn embedding_input_is_non_empty(value: &Value) -> bool {
|
|
match value {
|
|
Value::String(text) => !text.trim().is_empty(),
|
|
Value::Array(items) if !items.is_empty() => embedding_array_input_is_non_empty(items),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn embedding_array_input_is_non_empty(items: &[Value]) -> bool {
|
|
items
|
|
.iter()
|
|
.all(|item| item.as_str().is_some_and(|text| !text.trim().is_empty()))
|
|
|| embedding_token_array_is_non_empty(items)
|
|
|| items.iter().all(|item| {
|
|
item.as_array()
|
|
.is_some_and(|items| embedding_token_array_is_non_empty(items))
|
|
})
|
|
|| items.iter().all(embedding_multimodal_content_is_non_empty)
|
|
}
|
|
|
|
fn embedding_token_array_is_non_empty(items: &[Value]) -> bool {
|
|
!items.is_empty() && items.iter().all(|item| item.as_u64().is_some())
|
|
}
|
|
|
|
fn embedding_multimodal_content_is_non_empty(value: &Value) -> bool {
|
|
let Some(object) = value.as_object() else {
|
|
return false;
|
|
};
|
|
let valid_text = object
|
|
.get("text")
|
|
.map(|value| value.as_str().is_some_and(|text| !text.trim().is_empty()));
|
|
let valid_image = object
|
|
.get("image")
|
|
.map(|value| value.as_str().is_some_and(|image| !image.trim().is_empty()));
|
|
let valid_video = object
|
|
.get("video")
|
|
.map(|value| value.as_str().is_some_and(|video| !video.trim().is_empty()));
|
|
let valid_multi_images = object.get("multi_images").map(|value| {
|
|
value.as_array().is_some_and(|items| {
|
|
!items.is_empty()
|
|
&& items
|
|
.iter()
|
|
.all(|item| item.as_str().is_some_and(|image| !image.trim().is_empty()))
|
|
})
|
|
});
|
|
|
|
[valid_text, valid_image, valid_video, valid_multi_images]
|
|
.into_iter()
|
|
.flatten()
|
|
.all(|valid| valid)
|
|
&& [valid_text, valid_image, valid_video, valid_multi_images]
|
|
.into_iter()
|
|
.flatten()
|
|
.any(|valid| valid)
|
|
}
|
|
|
|
fn image_request_count(value: &Value) -> Option<u64> {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| value.as_i64().and_then(|number| u64::try_from(number).ok()))
|
|
.or_else(|| {
|
|
value
|
|
.as_str()
|
|
.and_then(|text| text.trim().parse::<u64>().ok())
|
|
})
|
|
}
|
|
|
|
fn parse_openai_image_validation_input(
|
|
operation: OpenAiImageOperation,
|
|
content_type: Option<&str>,
|
|
request_body: &Bytes,
|
|
) -> Result<OpenAiImageValidationInput, &'static str> {
|
|
if request_body.is_empty() {
|
|
return Err(match operation {
|
|
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
|
|
OPENAI_IMAGE_PROMPT_DETAIL
|
|
}
|
|
});
|
|
}
|
|
|
|
let content_type = content_type.unwrap_or_default();
|
|
if content_type
|
|
.to_ascii_lowercase()
|
|
.contains("multipart/form-data")
|
|
{
|
|
parse_openai_image_validation_input_from_multipart(request_body, content_type)
|
|
} else {
|
|
parse_openai_image_validation_input_from_json(request_body)
|
|
}
|
|
}
|
|
|
|
fn parse_openai_image_validation_input_from_json(
|
|
request_body: &Bytes,
|
|
) -> Result<OpenAiImageValidationInput, &'static str> {
|
|
let payload = serde_json::from_slice::<Value>(request_body)
|
|
.map_err(|_| OPENAI_IMAGE_INVALID_JSON_DETAIL)?;
|
|
let object = payload
|
|
.as_object()
|
|
.ok_or(OPENAI_IMAGE_INVALID_JSON_DETAIL)?;
|
|
|
|
Ok(OpenAiImageValidationInput {
|
|
model: normalize_openai_image_model_for_operation(
|
|
object.get("model").and_then(Value::as_str),
|
|
),
|
|
prompt: object
|
|
.get("prompt")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned),
|
|
image_count: count_json_images(object),
|
|
n: object.get("n").and_then(image_request_count),
|
|
stream: object
|
|
.get("stream")
|
|
.and_then(value_as_bool)
|
|
.unwrap_or(false),
|
|
partial_images: object.get("partial_images").and_then(image_request_count),
|
|
response_format: object
|
|
.get("response_format")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
output_format: object
|
|
.get("output_format")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
quality: object
|
|
.get("quality")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
background: object
|
|
.get("background")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
moderation: object
|
|
.get("moderation")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
input_fidelity: object
|
|
.get("input_fidelity")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
output_compression: object
|
|
.get("output_compression")
|
|
.and_then(image_request_count),
|
|
style_present: object
|
|
.get("style")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty()),
|
|
})
|
|
}
|
|
|
|
fn parse_openai_image_validation_input_from_multipart(
|
|
request_body: &Bytes,
|
|
content_type: &str,
|
|
) -> Result<OpenAiImageValidationInput, &'static str> {
|
|
let boundary = multipart_boundary(content_type).ok_or(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL)?;
|
|
let fields = parse_multipart_fields(request_body, &boundary);
|
|
if fields.is_empty() {
|
|
return Err(OPENAI_IMAGE_INVALID_MULTIPART_DETAIL);
|
|
}
|
|
|
|
let model = fields
|
|
.iter()
|
|
.find(|field| field.name.trim() == "model")
|
|
.map(|field| String::from_utf8_lossy(&field.data).trim().to_string());
|
|
|
|
Ok(OpenAiImageValidationInput {
|
|
model: normalize_openai_image_model_for_operation(model.as_deref()),
|
|
prompt: multipart_text_field(&fields, "prompt"),
|
|
image_count: fields
|
|
.iter()
|
|
.filter(|field| {
|
|
matches!(
|
|
field.name.trim(),
|
|
"image" | "image[]" | "images" | "images[]"
|
|
)
|
|
})
|
|
.count(),
|
|
n: multipart_text_field(&fields, "n").and_then(|value| value.trim().parse::<u64>().ok()),
|
|
stream: multipart_text_field(&fields, "stream")
|
|
.and_then(|value| parse_bool_string(&value))
|
|
.unwrap_or(false),
|
|
partial_images: multipart_text_field(&fields, "partial_images")
|
|
.and_then(|value| value.trim().parse::<u64>().ok()),
|
|
response_format: multipart_text_field(&fields, "response_format")
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
output_format: multipart_text_field(&fields, "output_format")
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
quality: multipart_text_field(&fields, "quality").map(|value| value.to_ascii_lowercase()),
|
|
background: multipart_text_field(&fields, "background")
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
moderation: multipart_text_field(&fields, "moderation")
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
input_fidelity: multipart_text_field(&fields, "input_fidelity")
|
|
.map(|value| value.to_ascii_lowercase()),
|
|
output_compression: multipart_text_field(&fields, "output_compression")
|
|
.and_then(|value| value.trim().parse::<u64>().ok()),
|
|
style_present: multipart_text_field(&fields, "style").is_some(),
|
|
})
|
|
}
|
|
|
|
fn normalize_openai_image_model_for_operation(model: Option<&str>) -> Option<String> {
|
|
model
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn count_json_images(object: &serde_json::Map<String, Value>) -> usize {
|
|
let mut count = 0usize;
|
|
if let Some(value) = object.get("image") {
|
|
count += json_image_count(value);
|
|
}
|
|
if let Some(values) = object.get("images").and_then(Value::as_array) {
|
|
count += values.iter().map(json_image_count).sum::<usize>();
|
|
}
|
|
count
|
|
}
|
|
|
|
fn json_image_count(value: &Value) -> usize {
|
|
match value {
|
|
Value::Array(values) => values.iter().map(json_image_count).sum(),
|
|
Value::String(text) => (!text.trim().is_empty()) as usize,
|
|
Value::Object(_) => 1,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
fn value_as_bool(value: &Value) -> Option<bool> {
|
|
value
|
|
.as_bool()
|
|
.or_else(|| value.as_str().and_then(parse_bool_string))
|
|
}
|
|
|
|
fn parse_bool_string(value: &str) -> Option<bool> {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"true" | "1" | "yes" => Some(true),
|
|
"false" | "0" | "no" => Some(false),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct MultipartField {
|
|
name: String,
|
|
data: Vec<u8>,
|
|
}
|
|
|
|
fn multipart_text_field(fields: &[MultipartField], name: &str) -> Option<String> {
|
|
fields
|
|
.iter()
|
|
.find(|field| field.name.trim() == name)
|
|
.map(|field| String::from_utf8_lossy(&field.data).trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
}
|
|
|
|
fn parse_multipart_fields(body: &[u8], boundary: &str) -> Vec<MultipartField> {
|
|
let delimiter = format!("--{boundary}").into_bytes();
|
|
let mut parts = Vec::new();
|
|
let mut cursor = 0usize;
|
|
|
|
while let Some(index) = find_subslice(&body[cursor..], &delimiter) {
|
|
let start = cursor + index + delimiter.len();
|
|
if body.get(start..start + 2) == Some(b"--") {
|
|
break;
|
|
}
|
|
let mut part = &body[start..];
|
|
if part.starts_with(b"\r\n") {
|
|
part = &part[2..];
|
|
}
|
|
let Some(next) = find_subslice(part, &delimiter) else {
|
|
break;
|
|
};
|
|
let raw = &part[..next];
|
|
let raw = raw.strip_suffix(b"\r\n").unwrap_or(raw);
|
|
if let Some(field) = parse_multipart_field(raw) {
|
|
parts.push(field);
|
|
}
|
|
cursor = start + next;
|
|
}
|
|
|
|
parts
|
|
}
|
|
|
|
fn multipart_boundary(content_type: &str) -> Option<String> {
|
|
content_type.split(';').find_map(|segment| {
|
|
let (key, value) = segment.trim().split_once('=')?;
|
|
if !key.trim().eq_ignore_ascii_case("boundary") {
|
|
return None;
|
|
}
|
|
let boundary = value.trim().trim_matches('"').trim();
|
|
(!boundary.is_empty()).then(|| boundary.to_string())
|
|
})
|
|
}
|
|
|
|
fn parse_multipart_field(raw: &[u8]) -> Option<MultipartField> {
|
|
let header_end = find_subslice(raw, b"\r\n\r\n")?;
|
|
let headers = &raw[..header_end];
|
|
let data = raw.get(header_end + 4..)?.to_vec();
|
|
let header_text = String::from_utf8_lossy(headers);
|
|
|
|
let mut name = None;
|
|
for line in header_text.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed
|
|
.to_ascii_lowercase()
|
|
.starts_with("content-disposition:")
|
|
{
|
|
name = extract_quoted_header_value(trimmed, "name");
|
|
}
|
|
}
|
|
|
|
Some(MultipartField { name: name?, data })
|
|
}
|
|
|
|
fn extract_quoted_header_value(header: &str, key: &str) -> Option<String> {
|
|
let pattern = format!("{key}=\"");
|
|
let start = header.find(&pattern)? + pattern.len();
|
|
let rest = &header[start..];
|
|
let end = rest.find('"')?;
|
|
Some(rest[..end].to_string())
|
|
}
|
|
|
|
fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|
if needle.is_empty() || haystack.len() < needle.len() {
|
|
return None;
|
|
}
|
|
haystack
|
|
.windows(needle.len())
|
|
.position(|window| window == needle)
|
|
}
|
|
|
|
fn maybe_build_local_ai_public_route_guard_response(
|
|
request_context: &GatewayPublicRequestContext,
|
|
) -> Option<Response<Body>> {
|
|
if request_context.request_path == "/upload/v1beta/files"
|
|
&& request_context.request_method != http::Method::POST
|
|
{
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::METHOD_NOT_ALLOWED,
|
|
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
|
|
));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn maybe_build_local_claude_count_tokens_response(
|
|
request_context: &GatewayPublicRequestContext,
|
|
request_body: Option<&Bytes>,
|
|
) -> Option<Response<Body>> {
|
|
let decision = request_context.control_decision.as_ref()?;
|
|
if decision.route_family.as_deref() != Some("claude")
|
|
|| decision.route_kind.as_deref() != Some("count_tokens")
|
|
|| request_context.request_method != http::Method::POST
|
|
|| request_context.request_path != "/v1/messages/count_tokens"
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let Some(request_body) = request_body else {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
CLAUDE_COUNT_TOKENS_MISSING_BODY_DETAIL,
|
|
));
|
|
};
|
|
|
|
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
|
|
Ok(payload) => payload,
|
|
Err(_) => {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
|
|
));
|
|
}
|
|
};
|
|
|
|
let input_tokens = match estimate_claude_count_tokens(&payload) {
|
|
Ok(tokens) => tokens,
|
|
Err(_) => {
|
|
return Some(build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
CLAUDE_COUNT_TOKENS_INVALID_PAYLOAD_DETAIL,
|
|
));
|
|
}
|
|
};
|
|
|
|
Some(Json(json!({ "input_tokens": input_tokens })).into_response())
|
|
}
|
|
|
|
fn maybe_build_local_antigravity_v1internal_response(
|
|
request_context: &GatewayPublicRequestContext,
|
|
request_body: Option<&Bytes>,
|
|
) -> Option<Response<Body>> {
|
|
let decision = request_context.control_decision.as_ref()?;
|
|
if decision.route_family.as_deref() != Some("antigravity")
|
|
|| request_context.request_method != http::Method::POST
|
|
{
|
|
return None;
|
|
}
|
|
|
|
match decision.route_kind.as_deref()? {
|
|
"load_code_assist" => {
|
|
Some(Json(build_antigravity_load_code_assist_payload()).into_response())
|
|
}
|
|
"fetch_available_models" => {
|
|
Some(Json(build_antigravity_fetch_available_models_payload()).into_response())
|
|
}
|
|
"fetch_user_info" => {
|
|
Some(Json(build_antigravity_fetch_user_info_payload()).into_response())
|
|
}
|
|
"fetch_admin_controls" => Some(Json(json!({})).into_response()),
|
|
"list_experiments" => Some(
|
|
Json(json!({
|
|
"experimentIds": [],
|
|
"flags": []
|
|
}))
|
|
.into_response(),
|
|
),
|
|
"record_code_assist_metrics" => Some(Json(json!({})).into_response()),
|
|
"set_user_settings" => Some(build_antigravity_set_user_settings_response(request_body)),
|
|
"stream_generate_content" => None,
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn build_antigravity_set_user_settings_response(request_body: Option<&Bytes>) -> Response<Body> {
|
|
let Some(request_body) = request_body else {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
ANTIGRAVITY_USER_SETTINGS_MISSING_BODY_DETAIL,
|
|
);
|
|
};
|
|
let payload = match serde_json::from_slice::<Value>(request_body) {
|
|
Ok(payload) => payload,
|
|
Err(_) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
ANTIGRAVITY_USER_SETTINGS_INVALID_JSON_DETAIL,
|
|
);
|
|
}
|
|
};
|
|
let Some(user_settings) = payload
|
|
.get("userSettings")
|
|
.filter(|value| value.is_object())
|
|
.cloned()
|
|
else {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
ANTIGRAVITY_USER_SETTINGS_INVALID_DETAIL,
|
|
);
|
|
};
|
|
|
|
Json(json!({ "userSettings": user_settings })).into_response()
|
|
}
|
|
|
|
fn build_antigravity_load_code_assist_payload() -> Value {
|
|
json!({
|
|
"allowedTiers": [
|
|
antigravity_free_tier_payload(true),
|
|
antigravity_standard_tier_payload()
|
|
],
|
|
"cloudaicompanionProject": "aether-antigravity-local",
|
|
"currentTier": antigravity_free_tier_payload(false),
|
|
"gcpManaged": false,
|
|
"paidTier": antigravity_paid_tier_payload(),
|
|
"upgradeSubscriptionUri": "https://codeassist.google.com/upgrade"
|
|
})
|
|
}
|
|
|
|
fn antigravity_free_tier_payload(include_default_marker: bool) -> Value {
|
|
if include_default_marker {
|
|
json!({
|
|
"id": "free-tier",
|
|
"name": "Antigravity",
|
|
"description": "Gemini-powered code suggestions and chat in multiple IDEs",
|
|
"privacyNotice": {
|
|
"showNotice": false
|
|
},
|
|
"isDefault": true
|
|
})
|
|
} else {
|
|
json!({
|
|
"id": "free-tier",
|
|
"name": "Antigravity",
|
|
"description": "Gemini-powered code suggestions and chat in multiple IDEs",
|
|
"privacyNotice": {
|
|
"showNotice": false
|
|
},
|
|
"upgradeSubscriptionUri": "https://codeassist.google.com/upgrade",
|
|
"upgradeSubscriptionText": "Upgrade for higher Antigravity request limits",
|
|
"upgradeSubscriptionType": "GDP_HELIUM"
|
|
})
|
|
}
|
|
}
|
|
|
|
fn antigravity_standard_tier_payload() -> Value {
|
|
json!({
|
|
"id": "standard-tier",
|
|
"name": "Antigravity",
|
|
"description": "Unlimited coding assistant with the most powerful Gemini models",
|
|
"userDefinedCloudaicompanionProject": true,
|
|
"privacyNotice": {},
|
|
"usesGcpTos": true
|
|
})
|
|
}
|
|
|
|
fn antigravity_paid_tier_payload() -> Value {
|
|
json!({
|
|
"id": "g1-pro-tier",
|
|
"name": "Google AI Pro",
|
|
"description": "Google AI Pro",
|
|
"upgradeSubscriptionUri": "https://antigravity.google/g1-upgrade",
|
|
"upgradeSubscriptionText": "Upgrade for the highest Antigravity request limits"
|
|
})
|
|
}
|
|
|
|
fn build_antigravity_fetch_user_info_payload() -> Value {
|
|
json!({
|
|
"regionCode": "US",
|
|
"userSettings": build_antigravity_default_user_settings_payload()
|
|
})
|
|
}
|
|
|
|
fn build_antigravity_default_user_settings_payload() -> Value {
|
|
json!({
|
|
"preferredModelId": "gemini-3.1-flash-lite"
|
|
})
|
|
}
|
|
|
|
fn build_antigravity_fetch_available_models_payload() -> Value {
|
|
json!({
|
|
"models": {
|
|
"gemini-3.5-flash-low": antigravity_model_payload("gemini-3.5-flash-low", "Gemini 3.5 Flash Low"),
|
|
"gemini-3-flash-agent": antigravity_model_payload("gemini-3-flash-agent", "Gemini 3 Flash Agent"),
|
|
"gemini-3.1-flash-lite": antigravity_model_payload("gemini-3.1-flash-lite", "Gemini 3.1 Flash Lite"),
|
|
"gemini-3.1-pro-low": antigravity_model_payload("gemini-3.1-pro-low", "Gemini 3.1 Pro Low"),
|
|
"gemini-3-flash": antigravity_model_payload("gemini-3-flash", "Gemini 3 Flash"),
|
|
"gemini-2.5-flash": antigravity_model_payload("gemini-2.5-flash", "Gemini 2.5 Flash"),
|
|
"gemini-2.5-flash-lite": antigravity_model_payload("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
|
|
"gemini-2.5-flash-thinking": antigravity_model_payload("gemini-2.5-flash-thinking", "Gemini 2.5 Flash Thinking"),
|
|
"gemini-2.5-pro": antigravity_model_payload("gemini-2.5-pro", "Gemini 2.5 Pro"),
|
|
"gemini-3.1-flash-image": antigravity_model_payload("gemini-3.1-flash-image", "Gemini 3.1 Flash Image"),
|
|
"tab_flash_lite_preview": antigravity_model_payload("tab_flash_lite_preview", "Tab Flash Lite Preview"),
|
|
"tab_jump_flash_lite_preview": antigravity_model_payload("tab_jump_flash_lite_preview", "Tab Jump Flash Lite Preview"),
|
|
"models/proactive-observer": antigravity_model_payload("models/proactive-observer", "Proactive Observer")
|
|
},
|
|
"agentModelSorts": [
|
|
{
|
|
"displayName": "Recommended",
|
|
"groups": [
|
|
{
|
|
"modelIds": [
|
|
"gemini-3.1-flash-lite",
|
|
"gemini-3-flash-agent",
|
|
"gemini-3.1-pro-low",
|
|
"gemini-3.5-flash-low"
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"audioTranscriptionModelIds": ["models/proactive-observer"],
|
|
"commandModelIds": ["gemini-3-flash"],
|
|
"commitMessageModelIds": ["gemini-3.1-flash-lite"],
|
|
"defaultAgentModelId": "gemini-3.1-flash-lite",
|
|
"deprecatedModelIds": {},
|
|
"experimentIds": [],
|
|
"imageGenerationModelIds": ["gemini-3.1-flash-image"],
|
|
"mqueryModelIds": ["gemini-3.1-flash-lite"],
|
|
"tabModelIds": ["tab_flash_lite_preview", "tab_jump_flash_lite_preview"],
|
|
"tieredModelIds": {
|
|
"flash": ["gemini-3-flash-agent"],
|
|
"flashLite": ["gemini-3.1-flash-lite"],
|
|
"pro": ["gemini-3.1-pro-low"]
|
|
},
|
|
"webSearchModelIds": ["gemini-3.1-flash-lite"]
|
|
})
|
|
}
|
|
|
|
fn antigravity_model_payload(id: &str, display_name: &str) -> Value {
|
|
let model = match id {
|
|
"gemini-2.5-flash" => "MODEL_GOOGLE_GEMINI_2_5_FLASH",
|
|
"gemini-2.5-flash-lite" => "MODEL_GOOGLE_GEMINI_2_5_FLASH_LITE",
|
|
"gemini-2.5-flash-thinking" => "MODEL_GOOGLE_GEMINI_2_5_FLASH_THINKING",
|
|
"gemini-2.5-pro" => "MODEL_GOOGLE_GEMINI_2_5_PRO",
|
|
"gemini-3-flash" => "MODEL_PLACEHOLDER_M18",
|
|
"gemini-3-flash-agent" => "MODEL_PLACEHOLDER_M132",
|
|
"gemini-3.1-flash-image" => "MODEL_PLACEHOLDER_M21",
|
|
"gemini-3.1-flash-lite" => "MODEL_PLACEHOLDER_M50",
|
|
"gemini-3.1-pro-low" => "MODEL_PLACEHOLDER_M36",
|
|
"gemini-3.5-flash-low" => "MODEL_PLACEHOLDER_M20",
|
|
"models/proactive-observer" => "MODEL_PLACEHOLDER_M70",
|
|
"tab_flash_lite_preview" => "MODEL_PLACEHOLDER_M19",
|
|
"tab_jump_flash_lite_preview" => "MODEL_PLACEHOLDER_M28",
|
|
_ => "MODEL_PLACEHOLDER_M20",
|
|
};
|
|
json!({
|
|
"apiProvider": "API_PROVIDER_GOOGLE_GEMINI",
|
|
"displayName": display_name,
|
|
"maxOutputTokens": 65536,
|
|
"maxTokens": 1048576,
|
|
"minThinkingBudget": 32,
|
|
"model": model,
|
|
"modelProvider": "MODEL_PROVIDER_GOOGLE",
|
|
"recommended": id == "gemini-3.1-flash-lite",
|
|
"supportedMimeTypes": {
|
|
"application/json": true,
|
|
"application/pdf": true,
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"text/markdown": true,
|
|
"text/plain": true
|
|
},
|
|
"supportsImages": true,
|
|
"supportsThinking": true,
|
|
"supportsVideo": true,
|
|
"thinkingBudget": 4000,
|
|
"tokenizerType": "LLAMA_WITH_SPECIAL"
|
|
})
|
|
}
|
|
|
|
async fn maybe_build_local_gemini_video_operations_response(
|
|
state: &AppState,
|
|
request_context: &GatewayPublicRequestContext,
|
|
decision: &GatewayControlDecision,
|
|
) -> Option<Response<Body>> {
|
|
if decision.route_family.as_deref() != Some("gemini")
|
|
|| decision.route_kind.as_deref() != Some("video")
|
|
{
|
|
return None;
|
|
}
|
|
|
|
if request_context.request_path == "/v1beta/operations" {
|
|
return Some(match request_context.request_method {
|
|
http::Method::GET => {
|
|
build_local_gemini_video_operations_list_response(state, decision).await
|
|
}
|
|
_ => build_ai_public_error_response(
|
|
http::StatusCode::METHOD_NOT_ALLOWED,
|
|
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
|
|
),
|
|
});
|
|
}
|
|
|
|
let Some(operation_path) = request_context
|
|
.request_path
|
|
.strip_prefix("/v1beta/operations/")
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
Some(match request_context.request_method {
|
|
http::Method::GET => {
|
|
build_local_gemini_video_operation_detail_response(state, decision, operation_path)
|
|
.await
|
|
}
|
|
http::Method::POST if operation_path.ends_with(":cancel") => {
|
|
build_local_gemini_video_operation_cancel_response(state, decision, operation_path)
|
|
.await
|
|
}
|
|
_ => build_ai_public_error_response(
|
|
http::StatusCode::METHOD_NOT_ALLOWED,
|
|
AI_PUBLIC_METHOD_NOT_ALLOWED_DETAIL,
|
|
),
|
|
})
|
|
}
|
|
|
|
async fn build_local_gemini_video_operations_list_response(
|
|
state: &AppState,
|
|
decision: &GatewayControlDecision,
|
|
) -> Response<Body> {
|
|
let Some(user_id) = decision
|
|
.auth_context
|
|
.as_ref()
|
|
.map(|auth_context| auth_context.user_id.trim())
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::UNAUTHORIZED,
|
|
AI_PUBLIC_UNAUTHORIZED_DETAIL,
|
|
);
|
|
};
|
|
|
|
let filter = VideoTaskQueryFilter {
|
|
user_id: Some(user_id.to_string()),
|
|
status: None,
|
|
model_substring: None,
|
|
client_api_format: Some("gemini:video".to_string()),
|
|
};
|
|
let tasks = match state.list_video_task_page(&filter, 0, 100).await {
|
|
Ok(tasks) => tasks,
|
|
Err(err) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("{err:?}"),
|
|
);
|
|
}
|
|
};
|
|
let operations = tasks
|
|
.into_iter()
|
|
.filter(is_gemini_video_task)
|
|
.map(|task| build_gemini_video_operation_payload(&task))
|
|
.collect::<Vec<_>>();
|
|
|
|
Json(json!({ "operations": operations })).into_response()
|
|
}
|
|
|
|
async fn build_local_gemini_video_operation_detail_response(
|
|
state: &AppState,
|
|
decision: &GatewayControlDecision,
|
|
operation_path: &str,
|
|
) -> Response<Body> {
|
|
let task =
|
|
match find_user_gemini_video_task_for_operation(state, decision, operation_path).await {
|
|
Ok(Some(task)) => task,
|
|
Ok(None) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::NOT_FOUND,
|
|
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("{err:?}"),
|
|
);
|
|
}
|
|
};
|
|
|
|
Json(build_gemini_video_operation_payload(&task)).into_response()
|
|
}
|
|
|
|
async fn build_local_gemini_video_operation_cancel_response(
|
|
state: &AppState,
|
|
decision: &GatewayControlDecision,
|
|
operation_path: &str,
|
|
) -> Response<Body> {
|
|
let task =
|
|
match find_user_gemini_video_task_for_operation(state, decision, operation_path).await {
|
|
Ok(Some(task)) => task,
|
|
Ok(None) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::NOT_FOUND,
|
|
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
return build_ai_public_error_response(
|
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("{err:?}"),
|
|
);
|
|
}
|
|
};
|
|
|
|
match crate::async_task::cancel_video_task_record(state, &task.id).await {
|
|
Ok(_) => Json(json!({})).into_response(),
|
|
Err(CancelVideoTaskError::NotFound) => build_ai_public_error_response(
|
|
http::StatusCode::NOT_FOUND,
|
|
GEMINI_VIDEO_TASK_NOT_FOUND_DETAIL,
|
|
),
|
|
Err(CancelVideoTaskError::InvalidStatus(status)) => build_ai_public_error_response(
|
|
http::StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"Cannot cancel task with status: {}",
|
|
video_task_status_name(status)
|
|
),
|
|
),
|
|
Err(CancelVideoTaskError::Response(response)) => response,
|
|
Err(CancelVideoTaskError::Gateway(err)) => build_ai_public_error_response(
|
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("{err:?}"),
|
|
),
|
|
}
|
|
}
|
|
|
|
async fn find_user_gemini_video_task_for_operation(
|
|
state: &AppState,
|
|
decision: &GatewayControlDecision,
|
|
operation_path: &str,
|
|
) -> Result<Option<StoredVideoTask>, GatewayError> {
|
|
let Some(user_id) = decision
|
|
.auth_context
|
|
.as_ref()
|
|
.map(|auth_context| auth_context.user_id.trim())
|
|
.filter(|value| !value.is_empty())
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let Some(short_id) = extract_short_id_from_gemini_operation_path(operation_path) else {
|
|
return Ok(None);
|
|
};
|
|
let Some(task) = state.find_video_task_by_short_id(short_id).await? else {
|
|
return Ok(None);
|
|
};
|
|
if task.user_id.as_deref().map(str::trim) != Some(user_id) || !is_gemini_video_task(&task) {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(task))
|
|
}
|
|
|
|
fn extract_short_id_from_gemini_operation_path(operation_path: &str) -> Option<&str> {
|
|
let trimmed = operation_path.trim_matches('/');
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let short_id = trimmed
|
|
.strip_suffix(":cancel")
|
|
.unwrap_or(trimmed)
|
|
.rsplit('/')
|
|
.next()?;
|
|
(!short_id.is_empty()).then_some(short_id)
|
|
}
|
|
|
|
fn is_gemini_video_task(task: &StoredVideoTask) -> bool {
|
|
matches!(
|
|
task.provider_api_format
|
|
.as_deref()
|
|
.or(task.client_api_format.as_deref())
|
|
.map(str::trim),
|
|
Some("gemini:video")
|
|
)
|
|
}
|
|
|
|
fn build_gemini_video_operation_payload(task: &StoredVideoTask) -> serde_json::Value {
|
|
match task.status {
|
|
VideoTaskStatus::Completed => json!({
|
|
"name": gemini_video_operation_name(task),
|
|
"done": true,
|
|
"response": {
|
|
"generateVideoResponse": {
|
|
"generatedSamples": [
|
|
{
|
|
"video": {
|
|
"uri": format!(
|
|
"/v1beta/files/aev_{}:download?alt=media",
|
|
gemini_operation_short_id(task)
|
|
),
|
|
"mimeType": "video/mp4",
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}),
|
|
VideoTaskStatus::Failed | VideoTaskStatus::Expired => json!({
|
|
"name": gemini_video_operation_name(task),
|
|
"done": true,
|
|
"error": {
|
|
"code": task.error_code.clone().unwrap_or_else(|| "UNKNOWN".to_string()),
|
|
"message": task
|
|
.error_message
|
|
.clone()
|
|
.unwrap_or_else(|| "Video generation failed".to_string()),
|
|
}
|
|
}),
|
|
_ => json!({
|
|
"name": gemini_video_operation_name(task),
|
|
"done": false,
|
|
"metadata": gemini_video_operation_metadata(task),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn gemini_video_operation_name(task: &StoredVideoTask) -> String {
|
|
format!(
|
|
"models/{}/operations/{}",
|
|
gemini_operation_model(task),
|
|
gemini_operation_short_id(task)
|
|
)
|
|
}
|
|
|
|
fn gemini_operation_model(task: &StoredVideoTask) -> String {
|
|
task.model
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
task.external_task_id.as_deref().and_then(|external_id| {
|
|
let parts = external_id.split('/').collect::<Vec<_>>();
|
|
if parts.len() >= 2 && parts[0] == "models" && !parts[1].trim().is_empty() {
|
|
Some(parts[1].trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or_else(|| "unknown".to_string())
|
|
}
|
|
|
|
fn gemini_operation_short_id(task: &StoredVideoTask) -> String {
|
|
task.short_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or(task.id.as_str())
|
|
.to_string()
|
|
}
|
|
|
|
fn gemini_video_operation_metadata(task: &StoredVideoTask) -> serde_json::Value {
|
|
task.request_metadata
|
|
.as_ref()
|
|
.and_then(|metadata| metadata.get("rust_local_snapshot"))
|
|
.and_then(|snapshot| snapshot.get("Gemini"))
|
|
.and_then(|gemini| gemini.get("metadata"))
|
|
.cloned()
|
|
.unwrap_or_else(|| json!({}))
|
|
}
|
|
|
|
fn video_task_status_name(status: VideoTaskStatus) -> &'static str {
|
|
match status {
|
|
VideoTaskStatus::Pending => "pending",
|
|
VideoTaskStatus::Submitted => "submitted",
|
|
VideoTaskStatus::Queued => "queued",
|
|
VideoTaskStatus::Processing => "processing",
|
|
VideoTaskStatus::Completed => "completed",
|
|
VideoTaskStatus::Failed => "failed",
|
|
VideoTaskStatus::Cancelled => "cancelled",
|
|
VideoTaskStatus::Expired => "expired",
|
|
VideoTaskStatus::Deleted => "deleted",
|
|
}
|
|
}
|
|
|
|
fn build_ai_public_error_response(
|
|
status: http::StatusCode,
|
|
detail: impl Into<String>,
|
|
) -> Response<Body> {
|
|
(status, Json(json!({ "detail": detail.into() }))).into_response()
|
|
}
|
|
|
|
fn estimate_claude_count_tokens(payload: &serde_json::Value) -> Result<u64, ()> {
|
|
let object = payload.as_object().ok_or(())?;
|
|
let model = object
|
|
.get("model")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or(())?;
|
|
if model.trim().is_empty() {
|
|
return Err(());
|
|
}
|
|
|
|
let messages = object
|
|
.get("messages")
|
|
.and_then(serde_json::Value::as_array)
|
|
.ok_or(())?;
|
|
|
|
let system_tokens = estimate_claude_system_tokens(object.get("system"))?;
|
|
let message_tokens = estimate_claude_message_tokens(messages)?;
|
|
Ok(system_tokens.saturating_add(message_tokens))
|
|
}
|
|
|
|
fn estimate_claude_system_tokens(system: Option<&serde_json::Value>) -> Result<u64, ()> {
|
|
let Some(system) = system else {
|
|
return Ok(0);
|
|
};
|
|
|
|
match system {
|
|
serde_json::Value::Null => Ok(0),
|
|
serde_json::Value::String(text) => Ok(estimate_text_tokens(text)),
|
|
serde_json::Value::Array(blocks) => {
|
|
let mut total = 0_u64;
|
|
for block in blocks {
|
|
let block = block.as_object().ok_or(())?;
|
|
if let Some(text) = block.get("text").and_then(serde_json::Value::as_str) {
|
|
total = total.saturating_add(estimate_text_tokens(text));
|
|
}
|
|
}
|
|
Ok(total)
|
|
}
|
|
serde_json::Value::Object(_) => Ok(0),
|
|
_ => Err(()),
|
|
}
|
|
}
|
|
|
|
fn estimate_claude_message_tokens(messages: &[serde_json::Value]) -> Result<u64, ()> {
|
|
let mut total = 0_u64;
|
|
|
|
for message in messages {
|
|
let message = message.as_object().ok_or(())?;
|
|
let role = message
|
|
.get("role")
|
|
.and_then(serde_json::Value::as_str)
|
|
.ok_or(())?;
|
|
if !matches!(role, "user" | "assistant") {
|
|
return Err(());
|
|
}
|
|
|
|
total = total.saturating_add(4);
|
|
let content = message.get("content").ok_or(())?;
|
|
match content {
|
|
serde_json::Value::String(text) => {
|
|
total = total.saturating_add(estimate_text_tokens(text));
|
|
}
|
|
serde_json::Value::Array(items) => {
|
|
for item in items {
|
|
let item = item.as_object().ok_or(())?;
|
|
if let Some(text) = item.get("text").and_then(serde_json::Value::as_str) {
|
|
total = total.saturating_add(estimate_text_tokens(text));
|
|
}
|
|
}
|
|
}
|
|
_ => return Err(()),
|
|
}
|
|
}
|
|
|
|
Ok(total)
|
|
}
|
|
|
|
fn estimate_text_tokens(text: &str) -> u64 {
|
|
if text.is_empty() {
|
|
return 0;
|
|
}
|
|
|
|
let char_count = text.chars().count() as u64;
|
|
std::cmp::max(1, char_count / 4)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
estimate_claude_count_tokens, parse_openai_image_validation_input, validate_openai_image_n,
|
|
OpenAiImageOperation,
|
|
};
|
|
use axum::body::Bytes;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn estimates_claude_count_tokens_from_system_and_messages() {
|
|
let payload = json!({
|
|
"model": "claude-sonnet-4-5",
|
|
"system": [{"type": "text", "text": "abcdefghijklmnop"}],
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "abcdefghijkl"
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "abcdefgh"},
|
|
{"type": "tool_use", "name": "ignored", "input": {"city": "SF"}}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
|
|
assert_eq!(estimate_claude_count_tokens(&payload), Ok(17));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_claude_count_tokens_payload() {
|
|
let payload = json!({
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [{"role": "system", "content": "bad"}]
|
|
});
|
|
|
|
assert_eq!(estimate_claude_count_tokens(&payload), Err(()));
|
|
}
|
|
|
|
#[test]
|
|
fn image_validation_accepts_custom_model_name() {
|
|
let body =
|
|
Bytes::from_static(br#"{"model":" Custom/Image-Model:V1 ","prompt":"draw an image"}"#);
|
|
|
|
let validation = parse_openai_image_validation_input(
|
|
OpenAiImageOperation::Generate,
|
|
Some("application/json"),
|
|
&body,
|
|
)
|
|
.expect("custom image model should validate");
|
|
|
|
assert_eq!(validation.model.as_deref(), Some("Custom/Image-Model:V1"));
|
|
}
|
|
|
|
#[test]
|
|
fn image_validation_accepts_multipart_with_mixed_case_boundary() {
|
|
let boundary = "------------------------OYNWsMZCt0ILTwn8naP4Gb";
|
|
let body = Bytes::from(format!(
|
|
concat!(
|
|
"--{boundary}\r\n",
|
|
"Content-Disposition: form-data; name=\"model\"\r\n\r\n",
|
|
"gpt-image-2\r\n",
|
|
"--{boundary}\r\n",
|
|
"Content-Disposition: form-data; name=\"prompt\"\r\n\r\n",
|
|
"edit this image\r\n",
|
|
"--{boundary}\r\n",
|
|
"Content-Disposition: form-data; name=\"image\"; filename=\"image.jpg\"\r\n",
|
|
"Content-Type: image/jpeg\r\n\r\n",
|
|
"image-bytes\r\n",
|
|
"--{boundary}--\r\n"
|
|
),
|
|
boundary = boundary,
|
|
));
|
|
|
|
let validation = parse_openai_image_validation_input(
|
|
OpenAiImageOperation::Edit,
|
|
Some(&format!("multipart/form-data; boundary={boundary}")),
|
|
&body,
|
|
)
|
|
.expect("multipart image edit should validate");
|
|
|
|
assert_eq!(validation.model.as_deref(), Some("gpt-image-2"));
|
|
assert_eq!(validation.prompt.as_deref(), Some("edit this image"));
|
|
assert_eq!(validation.image_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn image_validation_restricts_multi_image_count_to_grok_models() {
|
|
let openai_body = Bytes::from_static(br#"{"model":"gpt-image-2","prompt":"draw","n":2}"#);
|
|
let openai_validation = parse_openai_image_validation_input(
|
|
OpenAiImageOperation::Generate,
|
|
Some("application/json"),
|
|
&openai_body,
|
|
)
|
|
.expect("valid image payload should parse");
|
|
|
|
assert_eq!(
|
|
validate_openai_image_n(&openai_validation).as_deref(),
|
|
Some("当前图片模型仅支持 n=1..1")
|
|
);
|
|
|
|
let grok_body =
|
|
Bytes::from_static(br#"{"model":"grok-imagine-image-lite","prompt":"draw","n":4}"#);
|
|
let grok_validation = parse_openai_image_validation_input(
|
|
OpenAiImageOperation::Generate,
|
|
Some("application/json"),
|
|
&grok_body,
|
|
)
|
|
.expect("valid grok image payload should parse");
|
|
|
|
assert!(validate_openai_image_n(&grok_validation).is_none());
|
|
}
|
|
}
|