feat: 添加 Antigravity 图像生成 API (OpenAI 兼容)

- 实现 /v1/images/generations 端点
- 添加 ImageGenerationRequest/Response 数据模型
- 实现 OpenAI 到 Antigravity 请求/响应转换器
- 支持 dall-e-3/dall-e-2 模型名映射到 gemini-3-pro-image
- 支持 b64_json 和 url 两种响应格式
- 添加 Python SDK 集成测试脚本
This commit is contained in:
liubu
2026-01-03 23:34:03 +08:00
parent a793de37d9
commit 3ff24e060e
7 changed files with 1491 additions and 24 deletions
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env python3
"""
OpenAI 兼容图像生成 API 测试脚本
使用 OpenAI Python SDK 测试 Antigravity 图像生成 API。
使用方法:
# 安装依赖
pip install openai
# 运行测试(需要先启动 API Server)
python scripts/test_image_api.py
# 指定自定义 API 地址和密钥
python scripts/test_image_api.py --base-url http://localhost:8999 --api-key your-key
环境变量:
PROXYCAST_BASE_URL: API 服务器地址(默认: http://localhost:8999)
PROXYCAST_API_KEY: API 密钥(默认: pc_LXZbIv3o78WpHuQwqgmwC0U4G0cY5UtQ)
"""
import argparse
import base64
import os
import sys
from datetime import datetime
try:
from openai import OpenAI
except ImportError:
print("错误: 请先安装 openai 库")
print("运行: pip install openai")
sys.exit(1)
def test_image_generation_url(client: OpenAI, prompt: str) -> bool:
"""
测试 URL 响应格式的图像生成
Args:
client: OpenAI 客户端
prompt: 图像生成提示词
Returns:
测试是否通过
"""
print("\n" + "=" * 60)
print("测试 1: URL 响应格式")
print("=" * 60)
print(f"提示词: {prompt}")
try:
response = client.images.generate(
model="dall-e-3", # 会被映射到 gemini-3-pro-image
prompt=prompt,
n=1,
size="1024x1024",
response_format="url"
)
# 验证响应结构
print(f"\n响应时间戳: {response.created}")
print(f"生成图片数量: {len(response.data)}")
if len(response.data) == 0:
print("❌ 错误: 没有生成图片")
return False
image = response.data[0]
# 验证 URL 格式
if image.url:
print(f"URL 长度: {len(image.url)} 字符")
if image.url.startswith("data:image/"):
print("✅ URL 格式正确 (data URL)")
else:
print(f"⚠️ URL 格式: {image.url[:50]}...")
else:
print("❌ 错误: URL 为空")
return False
# 验证 revised_prompt
if image.revised_prompt:
print(f"修订提示词: {image.revised_prompt[:100]}...")
else:
print("ℹ️ 没有修订提示词")
print("\n✅ 测试 1 通过")
return True
except Exception as e:
print(f"\n❌ 测试 1 失败: {e}")
return False
def test_image_generation_b64(client: OpenAI, prompt: str) -> bool:
"""
测试 b64_json 响应格式的图像生成
Args:
client: OpenAI 客户端
prompt: 图像生成提示词
Returns:
测试是否通过
"""
print("\n" + "=" * 60)
print("测试 2: b64_json 响应格式")
print("=" * 60)
print(f"提示词: {prompt}")
try:
response = client.images.generate(
model="gemini-3-pro-image-preview", # 直接使用 Gemini 模型名
prompt=prompt,
n=1,
response_format="b64_json"
)
# 验证响应结构
print(f"\n响应时间戳: {response.created}")
print(f"生成图片数量: {len(response.data)}")
if len(response.data) == 0:
print("❌ 错误: 没有生成图片")
return False
image = response.data[0]
# 验证 b64_json 格式
if image.b64_json:
print(f"Base64 数据长度: {len(image.b64_json)} 字符")
# 尝试解码验证
try:
decoded = base64.b64decode(image.b64_json)
print(f"解码后大小: {len(decoded)} 字节")
# 检查图片魔数
if decoded[:8] == b'\x89PNG\r\n\x1a\n':
print("✅ 图片格式: PNG")
elif decoded[:2] == b'\xff\xd8':
print("✅ 图片格式: JPEG")
elif decoded[:4] == b'GIF8':
print("✅ 图片格式: GIF")
elif decoded[:4] == b'RIFF':
print("✅ 图片格式: WebP")
else:
print(f"⚠️ 未知图片格式: {decoded[:8].hex()}")
except Exception as e:
print(f"⚠️ Base64 解码失败: {e}")
else:
print("❌ 错误: b64_json 为空")
return False
# 验证 revised_prompt
if image.revised_prompt:
print(f"修订提示词: {image.revised_prompt[:100]}...")
else:
print("ℹ️ 没有修订提示词")
print("\n✅ 测试 2 通过")
return True
except Exception as e:
print(f"\n❌ 测试 2 失败: {e}")
return False
def test_error_handling(client: OpenAI) -> bool:
"""
测试错误处理
Args:
client: OpenAI 客户端
Returns:
测试是否通过
"""
print("\n" + "=" * 60)
print("测试 3: 错误处理")
print("=" * 60)
try:
# 测试空提示词
print("测试空提示词...")
try:
response = client.images.generate(
model="dall-e-3",
prompt="", # 空提示词
n=1
)
print("❌ 错误: 应该拒绝空提示词")
return False
except Exception as e:
error_msg = str(e).lower()
if "prompt" in error_msg or "empty" in error_msg or "required" in error_msg:
print(f"✅ 正确拒绝空提示词: {e}")
else:
print(f"⚠️ 收到错误但消息不明确: {e}")
print("\n✅ 测试 3 通过")
return True
except Exception as e:
print(f"\n❌ 测试 3 失败: {e}")
return False
def test_response_structure(client: OpenAI, prompt: str) -> bool:
"""
测试响应结构符合 OpenAI 规范
Args:
client: OpenAI 客户端
prompt: 图像生成提示词
Returns:
测试是否通过
"""
print("\n" + "=" * 60)
print("测试 4: 响应结构验证")
print("=" * 60)
print(f"提示词: {prompt}")
try:
response = client.images.generate(
model="dall-e-3",
prompt=prompt,
n=1,
response_format="url"
)
# 验证 created 字段
if response.created:
print(f"✅ created 字段存在: {response.created}")
# 验证是有效的 Unix 时间戳
if response.created > 0:
dt = datetime.fromtimestamp(response.created)
print(f" 时间: {dt}")
else:
print("❌ created 不是有效时间戳")
return False
else:
print("❌ created 字段缺失")
return False
# 验证 data 字段
if response.data is not None:
print(f"✅ data 字段存在: {len(response.data)} 项")
if len(response.data) > 0:
print("✅ data 数组非空")
else:
print("❌ data 数组为空")
return False
else:
print("❌ data 字段缺失")
return False
# 验证每个图片项
for i, image in enumerate(response.data):
print(f"\n图片 {i + 1}:")
has_url = image.url is not None
has_b64 = image.b64_json is not None
if has_url:
print(f" ✅ url 字段存在")
if has_b64:
print(f" ✅ b64_json 字段存在")
if not has_url and not has_b64:
print(f" ❌ 缺少 url 和 b64_json")
return False
if image.revised_prompt:
print(f" ✅ revised_prompt 字段存在")
else:
print(f" ℹ️ revised_prompt 字段为空")
print("\n✅ 测试 4 通过")
return True
except Exception as e:
print(f"\n❌ 测试 4 失败: {e}")
return False
def main():
parser = argparse.ArgumentParser(
description="测试 OpenAI 兼容图像生成 API"
)
parser.add_argument(
"--base-url",
default=os.environ.get("PROXYCAST_BASE_URL", "http://localhost:8999"),
help="API 服务器地址"
)
parser.add_argument(
"--api-key",
default=os.environ.get("PROXYCAST_API_KEY", "pc_LXZbIv3o78WpHuQwqgmwC0U4G0cY5UtQ"),
help="API 密钥"
)
parser.add_argument(
"--prompt",
default="A cute fluffy cat sitting on a windowsill, looking at the sunset",
help="测试用的图像生成提示词"
)
parser.add_argument(
"--skip-generation",
action="store_true",
help="跳过实际图像生成测试(仅测试错误处理)"
)
args = parser.parse_args()
print("=" * 60)
print("OpenAI 兼容图像生成 API 测试")
print("=" * 60)
print(f"API 地址: {args.base_url}")
print(f"API 密钥: {args.api_key[:8]}...")
print(f"测试提示词: {args.prompt[:50]}...")
# 创建 OpenAI 客户端
client = OpenAI(
base_url=f"{args.base_url}/v1",
api_key=args.api_key
)
results = []
if not args.skip_generation:
# 测试 1: URL 响应格式
results.append(("URL 响应格式", test_image_generation_url(client, args.prompt)))
# 测试 2: b64_json 响应格式
results.append(("b64_json 响应格式", test_image_generation_b64(client, args.prompt)))
# 测试 4: 响应结构验证
results.append(("响应结构验证", test_response_structure(client, args.prompt)))
# 测试 3: 错误处理
results.append(("错误处理", test_error_handling(client)))
# 打印总结
print("\n" + "=" * 60)
print("测试总结")
print("=" * 60)
passed = 0
failed = 0
for name, result in results:
status = "✅ 通过" if result else "❌ 失败"
print(f" {name}: {status}")
if result:
passed += 1
else:
failed += 1
print(f"\n总计: {passed} 通过, {failed} 失败")
if failed > 0:
sys.exit(1)
else:
print("\n🎉 所有测试通过!")
sys.exit(0)
if __name__ == "__main__":
main()
+672 -24
View File
@@ -152,8 +152,9 @@ pub struct AntigravityRequestInner {
pub system_instruction: Option<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_config: Option<GeminiGenerationConfig>,
/// 工具定义 - 支持 Gemini 格式(function_declarations)和 Claude 格式(custom + input_schema)
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<GeminiTool>>,
pub tools: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -251,6 +252,12 @@ fn is_image_generation_model(model: &str) -> bool {
model == "gemini-3-pro-image" || model == "gemini-3-pro-image-preview"
}
/// 检查是否是 Claude 模型(通过 Antigravity 访问)
/// Claude 模型需要使用不同的工具格式(custom + input_schema)
fn is_claude_model(model: &str) -> bool {
model.starts_with("claude-") || model.contains("claude")
}
// ============================================================================
// 主转换函数
// ============================================================================
@@ -536,6 +543,15 @@ pub fn convert_openai_to_antigravity_with_context(
"[ANTIGRAVITY] 图片生成模型 {} 已启用 IMAGE 响应模态",
actual_model
);
eprintln!(
"[ANTIGRAVITY] 图片生成模型 {} 已启用 IMAGE 响应模态",
actual_model
);
} else {
eprintln!(
"[ANTIGRAVITY] 模型 {} 不是图片生成模型,不启用 IMAGE 响应模态",
actual_model
);
}
// 处理 reasoning_effort(思维链配置)
@@ -573,30 +589,49 @@ pub fn convert_openai_to_antigravity_with_context(
}
// 转换工具定义
let tools: Option<Vec<GeminiTool>> = request.tools.as_ref().and_then(|tools| {
let mut function_declarations: Vec<GeminiFunctionDeclaration> = Vec::new();
// 注意:Antigravity API 统一使用 Gemini 格式(function_declarations)
// Claude 模型在 Antigravity 内部会自动转换
let tools: Option<serde_json::Value> = request.tools.as_ref().and_then(|tools| {
let is_claude = is_claude_model(actual_model);
// Gemini 模型使用 function_declarations + parametersJsonSchema
// Claude 模型使用 function_declarations + inputSchema(注意字段名不同)
let mut function_declarations: Vec<serde_json::Value> = Vec::new();
for t in tools {
match t {
Tool::Function { function } => {
// 转换 parameters -> parametersJsonSchema
let params_schema = function.parameters.as_ref().map(|p| {
let mut schema = clean_parameters(Some(p.clone())).unwrap_or_default();
// 确保有 type 和 properties
if schema.get("type").is_none() {
schema["type"] = serde_json::json!("object");
}
if schema.get("properties").is_none() {
schema["properties"] = serde_json::json!({});
}
schema
});
let params_schema = function
.parameters
.as_ref()
.map(|p| {
let mut schema = clean_parameters(Some(p.clone())).unwrap_or_default();
// 确保有 type 和 properties
if schema.get("type").is_none() {
schema["type"] = serde_json::json!("object");
}
if schema.get("properties").is_none() {
schema["properties"] = serde_json::json!({});
}
schema
})
.unwrap_or_else(|| serde_json::json!({"type": "object", "properties": {}}));
function_declarations.push(GeminiFunctionDeclaration {
name: function.name.clone(),
description: function.description.clone(),
parameters_json_schema: params_schema,
});
if is_claude {
// Claude 模型使用 inputSchema 字段名
function_declarations.push(serde_json::json!({
"name": function.name,
"description": function.description.clone().unwrap_or_default(),
"inputSchema": params_schema
}));
} else {
// Gemini 模型使用 parametersJsonSchema 字段名
function_declarations.push(serde_json::json!({
"name": function.name,
"description": function.description.clone(),
"parametersJsonSchema": params_schema
}));
}
}
Tool::WebSearch | Tool::WebSearch20250305 => {
// web_search 工具不转换
@@ -607,10 +642,9 @@ pub fn convert_openai_to_antigravity_with_context(
if function_declarations.is_empty() {
None
} else {
Some(vec![GeminiTool {
function_declarations: Some(function_declarations),
google_search: None,
}])
Some(serde_json::json!([{
"functionDeclarations": function_declarations
}]))
}
});
@@ -965,3 +999,617 @@ pub fn convert_antigravity_to_openai_response(
response
}
// ============================================================================
// 图像生成 API 转换函数
// ============================================================================
use crate::models::openai::{ImageData, ImageGenerationRequest, ImageGenerationResponse};
/// 图像生成模型名称映射
///
/// 注意:Antigravity API 使用内部模型名称 `gemini-3-pro-image`,
/// 而不是用户友好名称 `gemini-3-pro-image-preview`。
fn image_model_mapping(model: &str) -> &str {
match model {
// OpenAI 兼容模型名 -> Antigravity 内部名称
"dall-e-3" | "dall-e-2" => "gemini-3-pro-image",
// 用户友好名称 -> 内部名称
"gemini-3-pro-image-preview" => "gemini-3-pro-image",
_ => model,
}
}
/// 将 OpenAI 图像生成请求转换为 Antigravity 格式
///
/// # 参数
/// - `request`: OpenAI 图像生成请求
/// - `project_id`: Antigravity 项目 ID
///
/// # 返回
/// Antigravity 格式的请求 JSON
pub fn convert_image_request_to_antigravity(
request: &ImageGenerationRequest,
project_id: &str,
) -> serde_json::Value {
// 模型映射
let actual_model = image_model_mapping(&request.model);
// 构建 Gemini 内容结构
let contents = vec![serde_json::json!({
"role": "user",
"parts": [{"text": request.prompt}]
})];
// 构建生成配置
let generation_config = serde_json::json!({
"temperature": 1.0,
"maxOutputTokens": 8096,
"responseModalities": ["TEXT", "IMAGE"],
"candidateCount": request.n
});
// 构建安全设置
let safety_settings = default_safety_settings();
// 构建完整请求
serde_json::json!({
"project": project_id,
"requestId": format!("img-{}", Uuid::new_v4()),
"request": {
"contents": contents,
"generationConfig": generation_config,
"safetySettings": safety_settings
},
"model": actual_model,
"userAgent": "antigravity"
})
}
/// 将 Antigravity 图像响应转换为 OpenAI 格式
///
/// # 参数
/// - `antigravity_resp`: Antigravity 响应 JSON
/// - `response_format`: 响应格式 ("url" 或 "b64_json")
///
/// # 返回
/// OpenAI 格式的图像生成响应,或错误信息
pub fn convert_antigravity_image_response(
antigravity_resp: &serde_json::Value,
response_format: &str,
) -> Result<ImageGenerationResponse, String> {
let resp = antigravity_resp.get("response").unwrap_or(antigravity_resp);
let mut images = Vec::new();
let mut revised_prompt: Option<String> = None;
if let Some(candidates) = resp.get("candidates").and_then(|c| c.as_array()) {
for candidate in candidates {
if let Some(parts) = candidate
.get("content")
.and_then(|c| c.get("parts"))
.and_then(|p| p.as_array())
{
for part in parts {
// 提取文本作为 revised_prompt
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
revised_prompt = Some(text.to_string());
}
}
// 提取图像数据
if let Some(inline_data) =
part.get("inlineData").or_else(|| part.get("inline_data"))
{
if let (Some(data), Some(mime_type)) = (
inline_data.get("data").and_then(|d| d.as_str()),
inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(|m| m.as_str()),
) {
let image_data = if response_format == "b64_json" {
ImageData {
b64_json: Some(data.to_string()),
url: None,
revised_prompt: revised_prompt.clone(),
}
} else {
// 构建 data URL
let data_url = format!("data:{};base64,{}", mime_type, data);
ImageData {
b64_json: None,
url: Some(data_url),
revised_prompt: revised_prompt.clone(),
}
};
images.push(image_data);
}
}
}
}
}
}
if images.is_empty() {
return Err("No image generated".to_string());
}
Ok(ImageGenerationResponse {
created: chrono::Utc::now().timestamp(),
data: images,
})
}
// ============================================================================
// 图像生成 API 测试
// ============================================================================
#[cfg(test)]
mod image_tests {
use super::*;
#[test]
fn test_image_model_mapping() {
// OpenAI 兼容模型名映射到内部名称
assert_eq!(image_model_mapping("dall-e-3"), "gemini-3-pro-image");
assert_eq!(image_model_mapping("dall-e-2"), "gemini-3-pro-image");
// 用户友好名称映射到内部名称
assert_eq!(
image_model_mapping("gemini-3-pro-image-preview"),
"gemini-3-pro-image"
);
// 内部名称保持不变
assert_eq!(
image_model_mapping("gemini-3-pro-image"),
"gemini-3-pro-image"
);
// 其他模型名称透传
assert_eq!(image_model_mapping("other-model"), "other-model");
}
#[test]
fn test_convert_image_request_basic() {
let request = ImageGenerationRequest {
prompt: "A cute cat".to_string(),
model: "gemini-3-pro-image-preview".to_string(),
n: 1,
size: None,
response_format: "url".to_string(),
quality: None,
style: None,
user: None,
};
let result = convert_image_request_to_antigravity(&request, "test-project");
// 验证基本结构
assert_eq!(result["project"], "test-project");
// 模型名应该映射为内部名称
assert_eq!(result["model"], "gemini-3-pro-image");
assert!(result["requestId"].as_str().unwrap().starts_with("img-"));
// 验证内容
let contents = result["request"]["contents"].as_array().unwrap();
assert_eq!(contents.len(), 1);
assert_eq!(contents[0]["role"], "user");
assert_eq!(contents[0]["parts"][0]["text"], "A cute cat");
// 验证生成配置
let gen_config = &result["request"]["generationConfig"];
let modalities = gen_config["responseModalities"].as_array().unwrap();
assert!(modalities.contains(&serde_json::json!("TEXT")));
assert!(modalities.contains(&serde_json::json!("IMAGE")));
assert_eq!(gen_config["candidateCount"], 1);
}
#[test]
fn test_convert_image_request_with_n() {
let request = ImageGenerationRequest {
prompt: "A beautiful sunset".to_string(),
model: "dall-e-3".to_string(),
n: 3,
size: Some("1024x1024".to_string()),
response_format: "b64_json".to_string(),
quality: Some("hd".to_string()),
style: Some("vivid".to_string()),
user: None,
};
let result = convert_image_request_to_antigravity(&request, "project-123");
// dall-e-3 应该映射为内部名称
assert_eq!(result["model"], "gemini-3-pro-image");
assert_eq!(result["request"]["generationConfig"]["candidateCount"], 3);
}
#[test]
fn test_convert_antigravity_image_response_b64_json() {
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [
{"text": "Here is your image"},
{
"inlineData": {
"mimeType": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
}
]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, "b64_json").unwrap();
assert!(result.created > 0);
assert_eq!(result.data.len(), 1);
assert!(result.data[0].b64_json.is_some());
assert!(result.data[0].url.is_none());
assert_eq!(
result.data[0].revised_prompt,
Some("Here is your image".to_string())
);
}
#[test]
fn test_convert_antigravity_image_response_url() {
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [{
"inlineData": {
"mimeType": "image/jpeg",
"data": "base64data"
}
}]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, "url").unwrap();
assert!(result.created > 0);
assert_eq!(result.data.len(), 1);
assert!(result.data[0].b64_json.is_none());
assert!(result.data[0].url.is_some());
assert_eq!(
result.data[0].url,
Some("data:image/jpeg;base64,base64data".to_string())
);
}
#[test]
fn test_convert_antigravity_image_response_no_image() {
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [{"text": "Sorry, I cannot generate that image"}]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, "url");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "No image generated");
}
#[test]
fn test_convert_antigravity_image_response_snake_case() {
// 测试 snake_case 字段名兼容性
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [{
"inline_data": {
"mime_type": "image/png",
"data": "testdata"
}
}]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, "b64_json").unwrap();
assert_eq!(result.data.len(), 1);
assert_eq!(result.data[0].b64_json, Some("testdata".to_string()));
}
}
// ============================================================================
// 图像生成 API 属性测试
// ============================================================================
#[cfg(test)]
mod image_property_tests {
use super::*;
use proptest::prelude::*;
// 生成随机提示词
fn arb_prompt() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 .,!?]{1,200}".prop_map(|s| s)
}
// 生成随机模型名称
fn arb_image_model() -> impl Strategy<Value = String> {
prop_oneof![
Just("dall-e-3".to_string()),
Just("dall-e-2".to_string()),
Just("gemini-3-pro-image-preview".to_string()),
Just("gemini-3-pro-image".to_string()),
Just("other-model".to_string()),
]
}
// 生成随机 n 值
fn arb_n() -> impl Strategy<Value = u32> {
1u32..5u32
}
// 生成随机响应格式
fn arb_response_format() -> impl Strategy<Value = String> {
prop_oneof![Just("url".to_string()), Just("b64_json".to_string()),]
}
// 生成随机图像请求
fn arb_image_request() -> impl Strategy<Value = ImageGenerationRequest> {
(
arb_prompt(),
arb_image_model(),
arb_n(),
arb_response_format(),
)
.prop_map(
|(prompt, model, n, response_format)| ImageGenerationRequest {
prompt,
model,
n,
size: None,
response_format,
quality: None,
style: None,
user: None,
},
)
}
// 生成随机 Base64 数据
fn arb_base64_data() -> impl Strategy<Value = String> {
"[a-zA-Z0-9+/]{10,100}={0,2}".prop_map(|s| s)
}
// 生成随机 MIME 类型
fn arb_mime_type() -> impl Strategy<Value = String> {
prop_oneof![
Just("image/png".to_string()),
Just("image/jpeg".to_string()),
Just("image/gif".to_string()),
Just("image/webp".to_string()),
]
}
proptest! {
/// Property 1: Request Conversion Correctness
///
/// *For any* valid OpenAI image generation request with a non-empty prompt,
/// the converted Antigravity request SHALL:
/// - Contain the prompt text in `request.contents[0].parts[0].text`
/// - Have `responseModalities` set to `["TEXT", "IMAGE"]`
/// - Use the correct mapped model name
/// - Include the specified `n` value in `candidateCount`
///
/// **Feature: antigravity-image-api, Property 1: Request Conversion Correctness**
/// **Validates: Requirements 1.2, 1.3, 1.4, 2.1, 2.2**
#[test]
fn prop_request_conversion_correctness(request in arb_image_request()) {
let result = convert_image_request_to_antigravity(&request, "test-project");
// 验证 prompt 正确传递
let contents = result["request"]["contents"].as_array().unwrap();
prop_assert_eq!(contents.len(), 1);
prop_assert_eq!(contents[0]["parts"][0]["text"].as_str().unwrap(), request.prompt.as_str());
// 验证 responseModalities 设置正确
let modalities = result["request"]["generationConfig"]["responseModalities"]
.as_array()
.unwrap();
prop_assert!(modalities.contains(&serde_json::json!("TEXT")));
prop_assert!(modalities.contains(&serde_json::json!("IMAGE")));
// 验证模型映射正确
let expected_model = image_model_mapping(&request.model);
prop_assert_eq!(result["model"].as_str().unwrap(), expected_model);
// 验证 n 值正确传递
prop_assert_eq!(
result["request"]["generationConfig"]["candidateCount"].as_u64().unwrap(),
request.n as u64
);
}
/// Property 2: Response Format Correctness
///
/// *For any* Antigravity response containing image data:
/// - WHEN response_format is "b64_json", the output SHALL have `b64_json` field set and `url` field null
/// - WHEN response_format is "url", the output SHALL have `url` field as a valid data URL and `b64_json` field null
///
/// **Feature: antigravity-image-api, Property 2: Response Format Correctness**
/// **Validates: Requirements 1.6, 1.7, 3.2, 3.3**
#[test]
fn prop_response_format_correctness(
base64_data in arb_base64_data(),
mime_type in arb_mime_type(),
response_format in arb_response_format()
) {
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [{
"inlineData": {
"mimeType": mime_type.clone(),
"data": base64_data.clone()
}
}]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, &response_format).unwrap();
prop_assert!(result.data.len() >= 1);
if response_format == "b64_json" {
// b64_json 格式
prop_assert!(result.data[0].b64_json.is_some());
prop_assert!(result.data[0].url.is_none());
prop_assert_eq!(result.data[0].b64_json.as_ref().unwrap(), &base64_data);
} else {
// url 格式
prop_assert!(result.data[0].b64_json.is_none());
prop_assert!(result.data[0].url.is_some());
// 验证 data URL 格式
let url = result.data[0].url.as_ref().unwrap();
let expected_url = format!("data:{};base64,{}", mime_type, base64_data);
prop_assert_eq!(url, &expected_url);
}
}
/// Property 3: OpenAI Response Structure Compliance
///
/// *For any* successful image generation response:
/// - The response SHALL have a `created` field with a valid Unix timestamp (positive integer)
/// - The response SHALL have a `data` field that is a non-empty array
/// - Each item in `data` SHALL have either `url` or `b64_json` field (not both)
///
/// **Feature: antigravity-image-api, Property 3: OpenAI Response Structure Compliance**
/// **Validates: Requirements 3.4, 3.5, 5.3, 5.4**
#[test]
fn prop_openai_response_structure(
base64_data in arb_base64_data(),
mime_type in arb_mime_type(),
response_format in arb_response_format()
) {
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": [{
"inlineData": {
"mimeType": mime_type,
"data": base64_data
}
}]
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, &response_format).unwrap();
// 验证 created 是有效时间戳
prop_assert!(result.created > 0);
// 验证 data 是非空数组
prop_assert!(!result.data.is_empty());
// 验证每个 item 只有 url 或 b64_json 之一
for item in &result.data {
let has_url = item.url.is_some();
let has_b64 = item.b64_json.is_some();
prop_assert!(has_url != has_b64, "Each item should have exactly one of url or b64_json");
}
}
/// Property 4: Model Name Mapping Correctness
///
/// *For any* model name in the request:
/// - "dall-e-3" SHALL map to "gemini-3-pro-image"
/// - "dall-e-2" SHALL map to "gemini-3-pro-image"
/// - "gemini-3-pro-image-preview" SHALL map to "gemini-3-pro-image"
/// - Other model names SHALL pass through unchanged
///
/// **Feature: antigravity-image-api, Property 4: Model Name Mapping Correctness**
/// **Validates: Requirements 2.3, 2.4**
#[test]
fn prop_model_name_mapping(model in arb_image_model()) {
let mapped = image_model_mapping(&model);
match model.as_str() {
"dall-e-3" | "dall-e-2" | "gemini-3-pro-image-preview" => {
prop_assert_eq!(mapped, "gemini-3-pro-image");
}
other => {
prop_assert_eq!(mapped, other);
}
}
}
/// Property 5: Image Data Extraction
///
/// *For any* Antigravity response with `inlineData` containing `data` and `mimeType`:
/// - The converter SHALL successfully extract the Base64 data
/// - The converter SHALL successfully extract the MIME type
/// - If text is present alongside the image, it SHALL be included in `revised_prompt`
///
/// **Feature: antigravity-image-api, Property 5: Image Data Extraction**
/// **Validates: Requirements 3.1, 3.6**
#[test]
fn prop_image_data_extraction(
base64_data in arb_base64_data(),
mime_type in arb_mime_type(),
text in proptest::option::of("[a-zA-Z0-9 ]{0,100}")
) {
let mut parts = vec![];
// 可选的文本部分
if let Some(ref t) = text {
if !t.is_empty() {
parts.push(serde_json::json!({"text": t}));
}
}
// 图像部分
parts.push(serde_json::json!({
"inlineData": {
"mimeType": mime_type.clone(),
"data": base64_data.clone()
}
}));
let antigravity_resp = serde_json::json!({
"response": {
"candidates": [{
"content": {
"parts": parts
}
}]
}
});
let result = convert_antigravity_image_response(&antigravity_resp, "b64_json").unwrap();
// 验证 Base64 数据提取
prop_assert_eq!(result.data[0].b64_json.as_ref().unwrap(), &base64_data);
// 验证 revised_prompt
if let Some(ref t) = text {
if !t.is_empty() {
prop_assert_eq!(result.data[0].revised_prompt.as_ref().unwrap(), t);
}
}
}
}
}
+79
View File
@@ -189,3 +189,82 @@ pub struct ChatCompletionChunk {
pub model: String,
pub choices: Vec<StreamChoice>,
}
// ============================================================================
// 图像生成 API 数据模型
// ============================================================================
/// OpenAI 图像生成请求
///
/// 兼容 OpenAI Images API,支持通过 Antigravity 生成图像。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageGenerationRequest {
/// 图像生成提示词
pub prompt: String,
/// 模型名称 (默认: gemini-3-pro-image-preview)
#[serde(default = "default_image_model")]
pub model: String,
/// 生成图像数量 (默认: 1)
#[serde(default = "default_n")]
pub n: u32,
/// 图像尺寸 (可选,Antigravity 可能忽略)
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
/// 响应格式: "url" 或 "b64_json" (默认: "url")
#[serde(default = "default_response_format")]
pub response_format: String,
/// 图像质量 (可选,Antigravity 可能忽略)
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
/// 图像风格 (可选,Antigravity 可能忽略)
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
/// 用户标识 (可选)
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
fn default_image_model() -> String {
"gemini-3-pro-image-preview".to_string()
}
fn default_n() -> u32 {
1
}
fn default_response_format() -> String {
"url".to_string()
}
/// OpenAI 图像生成响应
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageGenerationResponse {
/// 创建时间戳 (Unix epoch seconds)
pub created: i64,
/// 生成的图像数组
pub data: Vec<ImageData>,
}
/// 单个图像数据
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageData {
/// Base64 编码的图像数据 (当 response_format="b64_json")
#[serde(skip_serializing_if = "Option::is_none")]
pub b64_json: Option<String>,
/// 图像 URL (当 response_format="url",返回 data URL)
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// 修订后的提示词 (如果 Antigravity 返回了文本)
#[serde(skip_serializing_if = "Option::is_none")]
pub revised_prompt: Option<String>,
}
+12
View File
@@ -2007,13 +2007,21 @@ impl StreamingProvider for AntigravityProvider {
match result {
Ok(resp) => {
let status = resp.status();
eprintln!("[ANTIGRAVITY_STREAM] HTTP 响应状态: {}", status);
tracing::info!("[ANTIGRAVITY_STREAM] HTTP 响应状态: {}", status);
if status.is_success() {
eprintln!("[ANTIGRAVITY_STREAM] ✓ 流式响应成功建立");
tracing::info!("[ANTIGRAVITY_STREAM] ✓ 流式响应成功建立,返回流");
return Ok(reqwest_stream_to_stream_response(resp));
} else {
let body = resp.text().await.unwrap_or_default();
eprintln!(
"[ANTIGRAVITY_STREAM] ✗ 请求失败\n Base URL: {}\n Status: {}\n Body: {}",
base_url,
status,
&body[..body.len().min(500)]
);
tracing::error!(
"[ANTIGRAVITY_STREAM] ✗ 请求失败\n Base URL: {}\n Status: {}\n Body: {}",
base_url,
@@ -2024,6 +2032,10 @@ impl StreamingProvider for AntigravityProvider {
}
}
Err(e) => {
eprintln!(
"[ANTIGRAVITY_STREAM] ✗ 连接失败\n Base URL: {}\n Error: {}",
base_url, e
);
tracing::error!(
"[ANTIGRAVITY_STREAM] ✗ 连接失败\n Base URL: {}\n Error: {}",
base_url,
@@ -0,0 +1,352 @@
//! 图像生成 API 处理器
//!
//! 实现 OpenAI 兼容的 `/v1/images/generations` 端点,
//! 通过 Antigravity Provider 调用 Gemini 图像生成模型。
//!
//! # 功能
//! - 接收 OpenAI 格式的图像生成请求
//! - 转换为 Antigravity/Gemini 格式
//! - 调用 Antigravity Provider
//! - 返回 OpenAI 格式的响应
//!
//! # 需求覆盖
//! - 需求 1.1: 实现 `/v1/images/generations` 端点
//! - 需求 4.1: 验证请求参数
//! - 需求 4.2: 获取 Antigravity 凭证
//! - 需求 4.3: 调用 Antigravity Provider
//! - 需求 4.4: 转换响应格式
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use crate::converter::openai_to_antigravity::{
convert_antigravity_image_response, convert_image_request_to_antigravity,
};
use crate::models::openai::ImageGenerationRequest;
use crate::models::provider_pool_model::CredentialData;
use crate::providers::AntigravityProvider;
use crate::server::handlers::verify_api_key;
use crate::server::AppState;
/// 处理图像生成请求
///
/// # 端点
/// `POST /v1/images/generations`
///
/// # 请求格式
/// ```json
/// {
/// "prompt": "A cute cat",
/// "model": "dall-e-3",
/// "n": 1,
/// "size": "1024x1024",
/// "response_format": "url"
/// }
/// ```
///
/// # 响应格式
/// ```json
/// {
/// "created": 1234567890,
/// "data": [
/// {
/// "url": "data:image/png;base64,...",
/// "revised_prompt": "A cute fluffy cat"
/// }
/// ]
/// }
/// ```
pub async fn handle_image_generation(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<ImageGenerationRequest>,
) -> Response {
// 验证 API Key
if let Err(e) = verify_api_key(&headers, &state.api_key).await {
return e.into_response();
}
// 验证请求参数
if request.prompt.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": "prompt is required and cannot be empty",
"type": "invalid_request_error",
"code": "invalid_prompt"
}
})),
)
.into_response();
}
// 记录请求日志
state.logs.write().await.add(
"info",
&format!(
"[IMAGE] 收到图像生成请求: model={}, prompt={}, n={}, response_format={}",
request.model,
if request.prompt.len() > 50 {
format!("{}...", &request.prompt[..50])
} else {
request.prompt.clone()
},
request.n,
request.response_format
),
);
// 获取 Antigravity 凭证
let db = match &state.db {
Some(db) => db,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": "Database not available",
"type": "server_error"
}
})),
)
.into_response();
}
};
// 从凭证池获取 Antigravity 凭证
let credential = match state
.pool_service
.select_credential(db, "antigravity", None)
{
Ok(Some(cred)) => cred,
Ok(None) => {
state
.logs
.write()
.await
.add("error", "[IMAGE] 没有可用的 Antigravity 凭证");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": {
"message": "No Antigravity credentials available for image generation",
"type": "server_error",
"code": "no_credentials"
}
})),
)
.into_response();
}
Err(e) => {
state
.logs
.write()
.await
.add("error", &format!("[IMAGE] 获取凭证失败: {}", e));
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("Failed to get credentials: {}", e),
"type": "server_error"
}
})),
)
.into_response();
}
};
// 提取 Antigravity 凭证信息
let (creds_file_path, project_id) = match &credential.credential {
CredentialData::AntigravityOAuth {
creds_file_path,
project_id,
} => (creds_file_path.clone(), project_id.clone()),
_ => {
state
.logs
.write()
.await
.add("error", "[IMAGE] 选中的凭证不是 Antigravity 类型");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": "Selected credential is not Antigravity type",
"type": "server_error"
}
})),
)
.into_response();
}
};
// 创建 Antigravity Provider
let mut antigravity = AntigravityProvider::new();
if let Err(e) = antigravity
.load_credentials_from_path(&creds_file_path)
.await
{
let _ = state.pool_service.mark_unhealthy(
db,
&credential.uuid,
Some(&format!("Failed to load credentials: {}", e)),
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("Failed to load Antigravity credentials: {}", e),
"type": "server_error"
}
})),
)
.into_response();
}
// 验证并刷新 Token
let validation_result = antigravity.validate_token();
if validation_result.needs_refresh() {
tracing::info!("[IMAGE] Token 需要刷新,开始刷新...");
if let Err(refresh_error) = antigravity.refresh_token_with_retry(3).await {
tracing::error!("[IMAGE] Token 刷新失败: {:?}", refresh_error);
let _ = state.pool_service.mark_unhealthy_with_details(
db,
&credential.uuid,
&refresh_error,
);
let (status, message) = if refresh_error.requires_reauth() {
(StatusCode::UNAUTHORIZED, refresh_error.user_message())
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
refresh_error.user_message(),
)
};
return (
status,
Json(serde_json::json!({
"error": {
"message": message,
"type": "authentication_error"
}
})),
)
.into_response();
}
}
// 设置项目 ID
if let Some(pid) = project_id {
antigravity.project_id = Some(pid);
} else if let Err(e) = antigravity.discover_project().await {
tracing::warn!("[IMAGE] Failed to discover project: {}", e);
}
let proj_id = antigravity.project_id.clone().unwrap_or_default();
// 转换请求为 Antigravity 格式
let antigravity_request = convert_image_request_to_antigravity(&request, &proj_id);
state.logs.write().await.add(
"debug",
&format!(
"[IMAGE] Antigravity 请求: model={}",
antigravity_request["model"].as_str().unwrap_or("unknown")
),
);
// 调用 Antigravity API - 直接使用 call_api 而不是 generate_content
// 因为 generate_content 内部的 to_gemini_response 会丢失嵌套在 response 字段下的数据
let model = antigravity_request["model"]
.as_str()
.unwrap_or("gemini-3-pro-image-preview");
eprintln!("[IMAGE] 调用 Antigravity API: model={}", model);
eprintln!(
"[IMAGE] 请求内容: {}",
serde_json::to_string_pretty(&antigravity_request).unwrap_or_default()
);
match antigravity
.call_api("generateContent", &antigravity_request)
.await
{
Ok(resp) => {
// 调试:打印原始响应
eprintln!(
"[IMAGE] Antigravity 原始响应: {}",
serde_json::to_string_pretty(&resp).unwrap_or_default()
);
state.logs.write().await.add(
"debug",
&format!(
"[IMAGE] Antigravity 原始响应: {}",
serde_json::to_string(&resp).unwrap_or_default()
),
);
// 转换响应为 OpenAI 格式
match convert_antigravity_image_response(&resp, &request.response_format) {
Ok(image_response) => {
// 记录成功
let _ = state
.pool_service
.mark_healthy(db, &credential.uuid, Some(model));
let _ = state.pool_service.record_usage(db, &credential.uuid);
state.logs.write().await.add(
"info",
&format!("[IMAGE] 图像生成成功: {} 张图片", image_response.data.len()),
);
(StatusCode::OK, Json(image_response)).into_response()
}
Err(e) => {
state
.logs
.write()
.await
.add("error", &format!("[IMAGE] 响应转换失败: {}", e));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": e,
"type": "server_error",
"code": "image_generation_failed"
}
})),
)
.into_response()
}
}
}
Err(e) => {
let _ = state
.pool_service
.mark_unhealthy(db, &credential.uuid, Some(&e.to_string()));
state
.logs
.write()
.await
.add("error", &format!("[IMAGE] Antigravity API 调用失败: {}", e));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("Image generation failed: {}", e),
"type": "server_error",
"code": "api_error"
}
})),
)
.into_response()
}
}
}
+2
View File
@@ -4,6 +4,7 @@
pub mod api;
pub mod credentials_api;
pub mod image_handler;
pub mod kiro_credential;
pub mod management;
pub mod provider_calls;
@@ -11,6 +12,7 @@ pub mod websocket;
pub use api::*;
pub use credentials_api::*;
pub use image_handler::*;
pub use kiro_credential::*;
pub use management::*;
pub use provider_calls::*;
+5
View File
@@ -871,6 +871,11 @@ async fn run_server(
.route("/v1/chat/completions", post(handlers::chat_completions))
.route("/v1/messages", post(handlers::anthropic_messages))
.route("/v1/messages/count_tokens", post(count_tokens))
// 图像生成 API 路由
.route(
"/v1/images/generations",
post(handlers::handle_image_generation),
)
// Gemini 原生协议路由
.route("/v1/gemini/*path", post(gemini_generate_content))
// WebSocket 路由