mirror of
https://github.com/TheSmallHanCat/flow2api.git
synced 2026-06-02 04:41:36 +08:00
feat: add model name resolver for Gemini generationConfig parameter-based conversion
Add a preprocessing layer before request handling that converts simplified
model names to internal MODEL_CONFIG keys based on generationConfig parameters
(aspectRatio, imageSize).
Changes:
- New src/core/model_resolver.py: model name resolution engine
- IMAGE_BASE_MODELS: 4 base image model aliases (gemini-2.5-flash-image,
gemini-3.0-pro-image, gemini-3.1-flash-image, imagen-4.0-generate-preview)
- VIDEO_BASE_MODELS: 13 base video model aliases with landscape/portrait mapping
- Supports Gemini ratio formats (16:9, 9:16, 1:1, 4:3, 3:4) and named formats
- Supports imageSize (2k, 4k) for compatible models
- Graceful fallback: unsupported ratio/size degrades to defaults
- Passthrough: existing MODEL_CONFIG keys and unknown models unchanged
- Modified src/core/models.py: extended ChatCompletionRequest
- Added ImageConfig, GenerationConfigParam Pydantic models
- Added generationConfig, contents optional fields
- Enabled extra='allow' for extra_body passthrough compatibility
- Modified src/api/routes.py: integrated resolver
- Added resolve_model_name() call before request processing
- Added /v1/models/aliases endpoint to list available aliases
Verified: 64/64 model key mappings match MODEL_CONFIG, 15 edge case tests pass.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""API routes - OpenAI compatible endpoints"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from typing import List, Optional
|
||||
@@ -12,6 +13,7 @@ from ..core.auth import verify_api_key_header
|
||||
from ..core.models import ChatCompletionRequest
|
||||
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
|
||||
from ..core.logger import debug_logger
|
||||
from ..core.model_resolver import resolve_model_name, get_base_model_aliases
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -48,11 +50,15 @@ async def retrieve_image_data(url: str) -> Optional[bytes]:
|
||||
# 回退逻辑:网络下载
|
||||
try:
|
||||
async with AsyncSession() as session:
|
||||
response = await session.get(url, timeout=30, impersonate="chrome110", verify=False)
|
||||
response = await session.get(
|
||||
url, timeout=30, impersonate="chrome110", verify=False
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
else:
|
||||
debug_logger.log_warning(f"[CONTEXT] 图片下载失败,状态码: {response.status_code}")
|
||||
debug_logger.log_warning(
|
||||
f"[CONTEXT] 图片下载失败,状态码: {response.status_code}"
|
||||
)
|
||||
except Exception as e:
|
||||
debug_logger.log_error(f"[CONTEXT] 图片下载异常: {str(e)}")
|
||||
|
||||
@@ -66,31 +72,57 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
|
||||
|
||||
for model_id, config in MODEL_CONFIG.items():
|
||||
description = f"{config['type'].capitalize()} generation"
|
||||
if config['type'] == 'image':
|
||||
if config["type"] == "image":
|
||||
description += f" - {config['model_name']}"
|
||||
else:
|
||||
description += f" - {config['model_key']}"
|
||||
|
||||
models.append({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"owned_by": "flow2api",
|
||||
"description": description
|
||||
})
|
||||
models.append(
|
||||
{
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"owned_by": "flow2api",
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"object": "list",
|
||||
"data": models
|
||||
}
|
||||
return {"object": "list", "data": models}
|
||||
|
||||
|
||||
@router.get("/v1/models/aliases")
|
||||
async def list_model_aliases(api_key: str = Depends(verify_api_key_header)):
|
||||
"""List simplified model name aliases that can be used with generationConfig"""
|
||||
aliases = get_base_model_aliases()
|
||||
alias_models = []
|
||||
for alias_id, description in aliases.items():
|
||||
alias_models.append(
|
||||
{
|
||||
"id": alias_id,
|
||||
"object": "model",
|
||||
"owned_by": "flow2api",
|
||||
"description": description,
|
||||
"is_alias": True,
|
||||
}
|
||||
)
|
||||
return {"object": "list", "data": alias_models}
|
||||
|
||||
|
||||
@router.post("/v1/chat/completions")
|
||||
async def create_chat_completion(
|
||||
request: ChatCompletionRequest,
|
||||
api_key: str = Depends(verify_api_key_header)
|
||||
request: ChatCompletionRequest, api_key: str = Depends(verify_api_key_header)
|
||||
):
|
||||
"""Create chat completion (unified endpoint for image and video generation)"""
|
||||
try:
|
||||
# ── 模型名解析:基于 generationConfig 参数转换简化模型名 ──
|
||||
original_model = request.model
|
||||
request.model = resolve_model_name(
|
||||
model=request.model, request=request, model_config=MODEL_CONFIG
|
||||
)
|
||||
if request.model != original_model:
|
||||
debug_logger.log_info(
|
||||
f"[ROUTE] 模型名已转换: {original_model} → {request.model}"
|
||||
)
|
||||
|
||||
# Extract prompt from messages
|
||||
if not request.messages:
|
||||
raise HTTPException(status_code=400, detail="Messages cannot be empty")
|
||||
@@ -120,18 +152,26 @@ async def create_chat_completion(
|
||||
image_base64 = match.group(1)
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
images.append(image_bytes)
|
||||
elif image_url.startswith("http://") or image_url.startswith("https://"):
|
||||
elif image_url.startswith("http://") or image_url.startswith(
|
||||
"https://"
|
||||
):
|
||||
# Download remote image URL
|
||||
debug_logger.log_info(f"[IMAGE_URL] 下载远程图片: {image_url}")
|
||||
try:
|
||||
downloaded_bytes = await retrieve_image_data(image_url)
|
||||
if downloaded_bytes and len(downloaded_bytes) > 0:
|
||||
images.append(downloaded_bytes)
|
||||
debug_logger.log_info(f"[IMAGE_URL] ✅ 远程图片下载成功: {len(downloaded_bytes)} 字节")
|
||||
debug_logger.log_info(
|
||||
f"[IMAGE_URL] ✅ 远程图片下载成功: {len(downloaded_bytes)} 字节"
|
||||
)
|
||||
else:
|
||||
debug_logger.log_warning(f"[IMAGE_URL] ⚠️ 远程图片下载失败或为空: {image_url}")
|
||||
debug_logger.log_warning(
|
||||
f"[IMAGE_URL] ⚠️ 远程图片下载失败或为空: {image_url}"
|
||||
)
|
||||
except Exception as e:
|
||||
debug_logger.log_error(f"[IMAGE_URL] ❌ 远程图片下载异常: {str(e)}")
|
||||
debug_logger.log_error(
|
||||
f"[IMAGE_URL] ❌ 远程图片下载异常: {str(e)}"
|
||||
)
|
||||
|
||||
# Fallback to deprecated image parameter
|
||||
if request.image and not images:
|
||||
@@ -145,8 +185,14 @@ async def create_chat_completion(
|
||||
# 自动参考图:仅对图片模型生效
|
||||
model_config = MODEL_CONFIG.get(request.model)
|
||||
|
||||
if model_config and model_config["type"] == "image" and len(request.messages) > 1:
|
||||
debug_logger.log_info(f"[CONTEXT] 开始查找历史参考图,消息数量: {len(request.messages)}")
|
||||
if (
|
||||
model_config
|
||||
and model_config["type"] == "image"
|
||||
and len(request.messages) > 1
|
||||
):
|
||||
debug_logger.log_info(
|
||||
f"[CONTEXT] 开始查找历史参考图,消息数量: {len(request.messages)}"
|
||||
)
|
||||
|
||||
# 查找上一次 assistant 回复的图片
|
||||
for msg in reversed(request.messages[:-1]):
|
||||
@@ -158,16 +204,24 @@ async def create_chat_completion(
|
||||
|
||||
if last_image_url.startswith("http"):
|
||||
try:
|
||||
downloaded_bytes = await retrieve_image_data(last_image_url)
|
||||
downloaded_bytes = await retrieve_image_data(
|
||||
last_image_url
|
||||
)
|
||||
if downloaded_bytes and len(downloaded_bytes) > 0:
|
||||
# 将历史图片插入到最前面
|
||||
images.insert(0, downloaded_bytes)
|
||||
debug_logger.log_info(f"[CONTEXT] ✅ 添加历史参考图: {last_image_url}")
|
||||
debug_logger.log_info(
|
||||
f"[CONTEXT] ✅ 添加历史参考图: {last_image_url}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
debug_logger.log_warning(f"[CONTEXT] 图片下载失败或为空,尝试下一个: {last_image_url}")
|
||||
debug_logger.log_warning(
|
||||
f"[CONTEXT] 图片下载失败或为空,尝试下一个: {last_image_url}"
|
||||
)
|
||||
except Exception as e:
|
||||
debug_logger.log_error(f"[CONTEXT] 处理参考图时出错: {str(e)}")
|
||||
debug_logger.log_error(
|
||||
f"[CONTEXT] 处理参考图时出错: {str(e)}"
|
||||
)
|
||||
# 继续尝试下一个图片
|
||||
|
||||
if not prompt:
|
||||
@@ -181,7 +235,7 @@ async def create_chat_completion(
|
||||
model=request.model,
|
||||
prompt=prompt,
|
||||
images=images if images else None,
|
||||
stream=True
|
||||
stream=True,
|
||||
):
|
||||
yield chunk
|
||||
|
||||
@@ -194,8 +248,8 @@ async def create_chat_completion(
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Non-streaming response
|
||||
@@ -204,7 +258,7 @@ async def create_chat_completion(
|
||||
model=request.model,
|
||||
prompt=prompt,
|
||||
images=images if images else None,
|
||||
stream=False
|
||||
stream=False,
|
||||
):
|
||||
result = chunk
|
||||
|
||||
@@ -217,7 +271,10 @@ async def create_chat_completion(
|
||||
# If not JSON, return as-is
|
||||
return JSONResponse(content={"result": result})
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Generation failed: No response from handler")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Generation failed: No response from handler",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
330
src/core/model_resolver.py
Normal file
330
src/core/model_resolver.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Model name resolver - converts simplified model names + generationConfig params to internal MODEL_CONFIG keys.
|
||||
|
||||
When upstream services (e.g. New API) send requests with a generic model name
|
||||
along with generationConfig containing aspectRatio / imageSize, this module
|
||||
resolves them to the specific internal model name used by flow2api.
|
||||
|
||||
Example:
|
||||
model = "gemini-3.0-pro-image"
|
||||
generationConfig.imageConfig.aspectRatio = "16:9"
|
||||
generationConfig.imageConfig.imageSize = "2k"
|
||||
→ resolved to "gemini-3.0-pro-image-landscape-2k"
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from ..core.logger import debug_logger
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 简化模型名 → 基础模型名前缀 的映射
|
||||
# ──────────────────────────────────────────────
|
||||
IMAGE_BASE_MODELS = {
|
||||
# Gemini 2.5 Flash (GEM_PIX)
|
||||
"gemini-2.5-flash-image": "gemini-2.5-flash-image",
|
||||
# Gemini 3.0 Pro (GEM_PIX_2)
|
||||
"gemini-3.0-pro-image": "gemini-3.0-pro-image",
|
||||
# Gemini 3.1 Flash (NARWHAL)
|
||||
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||
# Imagen 4.0 (IMAGEN_3_5)
|
||||
"imagen-4.0-generate-preview": "imagen-4.0-generate-preview",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# aspectRatio 转换映射
|
||||
# 支持 Gemini 原生格式 ("16:9") 和内部格式 ("landscape")
|
||||
# ──────────────────────────────────────────────
|
||||
ASPECT_RATIO_MAP = {
|
||||
# Gemini 标准 ratio 格式
|
||||
"16:9": "landscape",
|
||||
"9:16": "portrait",
|
||||
"1:1": "square",
|
||||
"4:3": "four-three",
|
||||
"3:4": "three-four",
|
||||
# 英文名直接映射
|
||||
"landscape": "landscape",
|
||||
"portrait": "portrait",
|
||||
"square": "square",
|
||||
"four-three": "four-three",
|
||||
"three-four": "three-four",
|
||||
"four_three": "four-three",
|
||||
"three_four": "three-four",
|
||||
# 大写形式
|
||||
"LANDSCAPE": "landscape",
|
||||
"PORTRAIT": "portrait",
|
||||
"SQUARE": "square",
|
||||
}
|
||||
|
||||
# 每个基础模型支持的 aspectRatio 列表
|
||||
# 如果请求的 ratio 不在支持列表中,降级到默认值
|
||||
MODEL_SUPPORTED_ASPECTS = {
|
||||
"gemini-2.5-flash-image": ["landscape", "portrait"],
|
||||
"gemini-3.0-pro-image": [
|
||||
"landscape",
|
||||
"portrait",
|
||||
"square",
|
||||
"four-three",
|
||||
"three-four",
|
||||
],
|
||||
"gemini-3.1-flash-image": [
|
||||
"landscape",
|
||||
"portrait",
|
||||
"square",
|
||||
"four-three",
|
||||
"three-four",
|
||||
],
|
||||
"imagen-4.0-generate-preview": ["landscape", "portrait"],
|
||||
}
|
||||
|
||||
# 每个基础模型支持的 imageSize(分辨率)列表
|
||||
MODEL_SUPPORTED_SIZES = {
|
||||
"gemini-2.5-flash-image": [], # 不支持放大
|
||||
"gemini-3.0-pro-image": ["2k", "4k"],
|
||||
"gemini-3.1-flash-image": ["2k", "4k"],
|
||||
"imagen-4.0-generate-preview": [], # 不支持放大
|
||||
}
|
||||
|
||||
# imageSize 归一化映射
|
||||
IMAGE_SIZE_MAP = {
|
||||
"2k": "2k",
|
||||
"2K": "2k",
|
||||
"4k": "4k",
|
||||
"4K": "4k",
|
||||
"": "",
|
||||
}
|
||||
|
||||
# 默认 aspectRatio
|
||||
DEFAULT_ASPECT = "landscape"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 视频模型简化名映射
|
||||
# ──────────────────────────────────────────────
|
||||
VIDEO_BASE_MODELS = {
|
||||
# T2V models
|
||||
"veo_3_1_t2v_fast": {
|
||||
"landscape": "veo_3_1_t2v_fast_landscape",
|
||||
"portrait": "veo_3_1_t2v_fast_portrait",
|
||||
},
|
||||
"veo_2_1_fast_d_15_t2v": {
|
||||
"landscape": "veo_2_1_fast_d_15_t2v_landscape",
|
||||
"portrait": "veo_2_1_fast_d_15_t2v_portrait",
|
||||
},
|
||||
"veo_2_0_t2v": {
|
||||
"landscape": "veo_2_0_t2v_landscape",
|
||||
"portrait": "veo_2_0_t2v_portrait",
|
||||
},
|
||||
"veo_3_1_t2v_fast_ultra": {
|
||||
"landscape": "veo_3_1_t2v_fast_ultra",
|
||||
"portrait": "veo_3_1_t2v_fast_portrait_ultra",
|
||||
},
|
||||
"veo_3_1_t2v_fast_ultra_relaxed": {
|
||||
"landscape": "veo_3_1_t2v_fast_ultra_relaxed",
|
||||
"portrait": "veo_3_1_t2v_fast_portrait_ultra_relaxed",
|
||||
},
|
||||
"veo_3_1_t2v": {
|
||||
"landscape": "veo_3_1_t2v_landscape",
|
||||
"portrait": "veo_3_1_t2v_portrait",
|
||||
},
|
||||
# I2V models
|
||||
"veo_3_1_i2v_s_fast_fl": {
|
||||
"landscape": "veo_3_1_i2v_s_fast_fl",
|
||||
"portrait": "veo_3_1_i2v_s_fast_portrait_fl",
|
||||
},
|
||||
"veo_2_1_fast_d_15_i2v": {
|
||||
"landscape": "veo_2_1_fast_d_15_i2v_landscape",
|
||||
"portrait": "veo_2_1_fast_d_15_i2v_portrait",
|
||||
},
|
||||
"veo_2_0_i2v": {
|
||||
"landscape": "veo_2_0_i2v_landscape",
|
||||
"portrait": "veo_2_0_i2v_portrait",
|
||||
},
|
||||
"veo_3_1_i2v_s_fast_ultra_fl": {
|
||||
"landscape": "veo_3_1_i2v_s_fast_ultra_fl",
|
||||
"portrait": "veo_3_1_i2v_s_fast_portrait_ultra_fl",
|
||||
},
|
||||
"veo_3_1_i2v_s_fast_ultra_relaxed": {
|
||||
"landscape": "veo_3_1_i2v_s_fast_ultra_relaxed",
|
||||
"portrait": "veo_3_1_i2v_s_fast_portrait_ultra_relaxed",
|
||||
},
|
||||
"veo_3_1_i2v_s": {
|
||||
"landscape": "veo_3_1_i2v_s_landscape",
|
||||
"portrait": "veo_3_1_i2v_s_portrait",
|
||||
},
|
||||
# R2V models
|
||||
"veo_3_1_r2v_fast": {
|
||||
"landscape": "veo_3_1_r2v_fast",
|
||||
"portrait": "veo_3_1_r2v_fast_portrait",
|
||||
},
|
||||
"veo_3_1_r2v_fast_ultra": {
|
||||
"landscape": "veo_3_1_r2v_fast_ultra",
|
||||
"portrait": "veo_3_1_r2v_fast_portrait_ultra",
|
||||
},
|
||||
"veo_3_1_r2v_fast_ultra_relaxed": {
|
||||
"landscape": "veo_3_1_r2v_fast_ultra_relaxed",
|
||||
"portrait": "veo_3_1_r2v_fast_portrait_ultra_relaxed",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _extract_generation_params(request) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""从请求中提取 aspectRatio 和 imageSize 参数。
|
||||
|
||||
优先级:
|
||||
1. request.generationConfig.imageConfig (顶层 Gemini 参数)
|
||||
2. extra fields 中的 generationConfig (extra_body 透传)
|
||||
|
||||
Returns:
|
||||
(aspect_ratio, image_size) 归一化后的值
|
||||
"""
|
||||
aspect_ratio = None
|
||||
image_size = None
|
||||
|
||||
# 尝试从 generationConfig 提取
|
||||
gen_config = getattr(request, "generationConfig", None)
|
||||
|
||||
# 如果顶层没有,尝试从 extra fields (Pydantic extra="allow")
|
||||
if gen_config is None and hasattr(request, "__pydantic_extra__"):
|
||||
extra = request.__pydantic_extra__ or {}
|
||||
gen_config_raw = extra.get("generationConfig")
|
||||
if isinstance(gen_config_raw, dict):
|
||||
image_config_raw = gen_config_raw.get("imageConfig", {})
|
||||
if isinstance(image_config_raw, dict):
|
||||
aspect_ratio = image_config_raw.get("aspectRatio")
|
||||
image_size = image_config_raw.get("imageSize")
|
||||
return (
|
||||
ASPECT_RATIO_MAP.get(aspect_ratio, aspect_ratio)
|
||||
if aspect_ratio
|
||||
else None,
|
||||
IMAGE_SIZE_MAP.get(image_size, image_size) if image_size else None,
|
||||
)
|
||||
|
||||
if gen_config is not None:
|
||||
image_config = getattr(gen_config, "imageConfig", None)
|
||||
if image_config is not None:
|
||||
aspect_ratio = getattr(image_config, "aspectRatio", None)
|
||||
image_size = getattr(image_config, "imageSize", None)
|
||||
|
||||
# 归一化
|
||||
if aspect_ratio:
|
||||
aspect_ratio = ASPECT_RATIO_MAP.get(aspect_ratio, aspect_ratio)
|
||||
if image_size:
|
||||
image_size = IMAGE_SIZE_MAP.get(image_size, image_size)
|
||||
|
||||
return aspect_ratio, image_size
|
||||
|
||||
|
||||
def resolve_model_name(
|
||||
model: str, request=None, model_config: Dict[str, Any] = None
|
||||
) -> str:
|
||||
"""将简化模型名 + generationConfig 参数解析为内部 MODEL_CONFIG key。
|
||||
|
||||
如果 model 已经是有效的 MODEL_CONFIG key,直接返回。
|
||||
如果 model 是简化名(基础模型名),则根据 generationConfig 中的
|
||||
aspectRatio / imageSize 拼接出完整的内部模型名。
|
||||
|
||||
Args:
|
||||
model: 请求中的模型名
|
||||
request: ChatCompletionRequest 实例(用于提取 generationConfig)
|
||||
model_config: MODEL_CONFIG 字典(用于验证解析后的模型名)
|
||||
|
||||
Returns:
|
||||
解析后的内部模型名
|
||||
"""
|
||||
# 如果已经是有效的 MODEL_CONFIG key,直接返回
|
||||
if model_config and model in model_config:
|
||||
return model
|
||||
|
||||
# ────── 图片模型解析 ──────
|
||||
if model in IMAGE_BASE_MODELS:
|
||||
base = IMAGE_BASE_MODELS[model]
|
||||
aspect_ratio, image_size = (
|
||||
_extract_generation_params(request) if request else (None, None)
|
||||
)
|
||||
|
||||
# 默认 aspect ratio
|
||||
if not aspect_ratio:
|
||||
aspect_ratio = DEFAULT_ASPECT
|
||||
|
||||
# 检查支持的 aspect ratio
|
||||
supported_aspects = MODEL_SUPPORTED_ASPECTS.get(base, [])
|
||||
if aspect_ratio not in supported_aspects and supported_aspects:
|
||||
debug_logger.log_warning(
|
||||
f"[MODEL_RESOLVER] 模型 {base} 不支持 aspectRatio={aspect_ratio},"
|
||||
f"降级到 {DEFAULT_ASPECT}"
|
||||
)
|
||||
aspect_ratio = DEFAULT_ASPECT
|
||||
|
||||
# 拼接模型名
|
||||
resolved = f"{base}-{aspect_ratio}"
|
||||
|
||||
# 检查支持的 imageSize
|
||||
if image_size:
|
||||
supported_sizes = MODEL_SUPPORTED_SIZES.get(base, [])
|
||||
if image_size in supported_sizes:
|
||||
resolved = f"{resolved}-{image_size}"
|
||||
else:
|
||||
debug_logger.log_warning(
|
||||
f"[MODEL_RESOLVER] 模型 {base} 不支持 imageSize={image_size},忽略"
|
||||
)
|
||||
|
||||
# 最终验证
|
||||
if model_config and resolved not in model_config:
|
||||
debug_logger.log_warning(
|
||||
f"[MODEL_RESOLVER] 解析后的模型名 {resolved} 不在 MODEL_CONFIG 中,"
|
||||
f"回退到原始模型名 {model}"
|
||||
)
|
||||
return model
|
||||
|
||||
debug_logger.log_info(
|
||||
f"[MODEL_RESOLVER] 模型名转换: {model} → {resolved} "
|
||||
f"(aspectRatio={aspect_ratio}, imageSize={image_size or 'default'})"
|
||||
)
|
||||
return resolved
|
||||
|
||||
# ────── 视频模型解析 ──────
|
||||
if model in VIDEO_BASE_MODELS:
|
||||
aspect_ratio, _ = (
|
||||
_extract_generation_params(request) if request else (None, None)
|
||||
)
|
||||
|
||||
# 视频默认横屏
|
||||
if not aspect_ratio or aspect_ratio not in ("landscape", "portrait"):
|
||||
aspect_ratio = "landscape"
|
||||
|
||||
orientation_map = VIDEO_BASE_MODELS[model]
|
||||
resolved = orientation_map.get(aspect_ratio)
|
||||
|
||||
if resolved and model_config and resolved in model_config:
|
||||
debug_logger.log_info(
|
||||
f"[MODEL_RESOLVER] 视频模型名转换: {model} → {resolved} "
|
||||
f"(aspectRatio={aspect_ratio})"
|
||||
)
|
||||
return resolved
|
||||
|
||||
debug_logger.log_warning(
|
||||
f"[MODEL_RESOLVER] 视频模型 {model} 解析失败 (aspect={aspect_ratio}),"
|
||||
f"使用原始模型名"
|
||||
)
|
||||
return model
|
||||
|
||||
# 未知模型名,原样返回(由下游 MODEL_CONFIG 校验报错)
|
||||
return model
|
||||
|
||||
|
||||
def get_base_model_aliases() -> Dict[str, str]:
|
||||
"""返回所有简化模型名(别名)及其描述,用于 /v1/models 接口展示。"""
|
||||
aliases = {}
|
||||
|
||||
for alias, base in IMAGE_BASE_MODELS.items():
|
||||
aspects = MODEL_SUPPORTED_ASPECTS.get(base, [])
|
||||
sizes = MODEL_SUPPORTED_SIZES.get(base, [])
|
||||
desc_parts = [f"aspects: {', '.join(aspects)}"]
|
||||
if sizes:
|
||||
desc_parts.append(f"sizes: {', '.join(sizes)}")
|
||||
aliases[alias] = f"Image generation (alias) - {'; '.join(desc_parts)}"
|
||||
|
||||
for alias in VIDEO_BASE_MODELS:
|
||||
aliases[alias] = (
|
||||
"Video generation (alias) - supports landscape/portrait via generationConfig"
|
||||
)
|
||||
|
||||
return aliases
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Data models for Flow2API"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Union, Any
|
||||
from datetime import datetime
|
||||
@@ -6,6 +7,7 @@ from datetime import datetime
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token model for Flow2API"""
|
||||
|
||||
id: Optional[int] = None
|
||||
|
||||
# 认证信息 (核心)
|
||||
@@ -48,6 +50,7 @@ class Token(BaseModel):
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Project model for VideoFX"""
|
||||
|
||||
id: Optional[int] = None
|
||||
project_id: str # VideoFX项目UUID
|
||||
token_id: int # 关联的Token ID
|
||||
@@ -59,6 +62,7 @@ class Project(BaseModel):
|
||||
|
||||
class TokenStats(BaseModel):
|
||||
"""Token statistics"""
|
||||
|
||||
token_id: int
|
||||
image_count: int = 0
|
||||
video_count: int = 0
|
||||
@@ -77,6 +81,7 @@ class TokenStats(BaseModel):
|
||||
|
||||
class Task(BaseModel):
|
||||
"""Generation task"""
|
||||
|
||||
id: Optional[int] = None
|
||||
task_id: str # Flow API返回的operation name
|
||||
token_id: int
|
||||
@@ -93,6 +98,7 @@ class Task(BaseModel):
|
||||
|
||||
class RequestLog(BaseModel):
|
||||
"""API request log"""
|
||||
|
||||
id: Optional[int] = None
|
||||
token_id: Optional[int] = None
|
||||
operation: str
|
||||
@@ -108,6 +114,7 @@ class RequestLog(BaseModel):
|
||||
|
||||
class AdminConfig(BaseModel):
|
||||
"""Admin configuration"""
|
||||
|
||||
id: int = 1
|
||||
username: str
|
||||
password: str
|
||||
@@ -117,6 +124,7 @@ class AdminConfig(BaseModel):
|
||||
|
||||
class ProxyConfig(BaseModel):
|
||||
"""Proxy configuration"""
|
||||
|
||||
id: int = 1
|
||||
enabled: bool = False # 请求代理开关
|
||||
proxy_url: Optional[str] = None # 请求代理地址
|
||||
@@ -126,6 +134,7 @@ class ProxyConfig(BaseModel):
|
||||
|
||||
class GenerationConfig(BaseModel):
|
||||
"""Generation timeout configuration"""
|
||||
|
||||
id: int = 1
|
||||
image_timeout: int = 300 # seconds
|
||||
video_timeout: int = 1500 # seconds
|
||||
@@ -133,6 +142,7 @@ class GenerationConfig(BaseModel):
|
||||
|
||||
class CacheConfig(BaseModel):
|
||||
"""Cache configuration"""
|
||||
|
||||
id: int = 1
|
||||
cache_enabled: bool = False
|
||||
cache_timeout: int = 7200 # seconds (2 hours), 0 means never expire
|
||||
@@ -143,6 +153,7 @@ class CacheConfig(BaseModel):
|
||||
|
||||
class DebugConfig(BaseModel):
|
||||
"""Debug configuration"""
|
||||
|
||||
id: int = 1
|
||||
enabled: bool = False
|
||||
log_requests: bool = True
|
||||
@@ -154,6 +165,7 @@ class DebugConfig(BaseModel):
|
||||
|
||||
class CaptchaConfig(BaseModel):
|
||||
"""Captcha configuration"""
|
||||
|
||||
id: int = 1
|
||||
captcha_method: str = "browser" # yescaptcha/capmonster/ezcaptcha/capsolver/browser/personal/remote_browser
|
||||
yescaptcha_api_key: str = ""
|
||||
@@ -178,6 +190,7 @@ class CaptchaConfig(BaseModel):
|
||||
|
||||
class PluginConfig(BaseModel):
|
||||
"""Plugin connection configuration"""
|
||||
|
||||
id: int = 1
|
||||
connection_token: str = "" # 插件连接token
|
||||
auto_enable_on_update: bool = True # 更新token时自动启用(默认开启)
|
||||
@@ -188,12 +201,31 @@ class PluginConfig(BaseModel):
|
||||
# OpenAI Compatible Request Models
|
||||
class ChatMessage(BaseModel):
|
||||
"""Chat message"""
|
||||
|
||||
role: str
|
||||
content: Union[str, List[dict]] # string or multimodal array
|
||||
|
||||
|
||||
class ImageConfig(BaseModel):
|
||||
"""Gemini imageConfig parameters"""
|
||||
|
||||
aspectRatio: Optional[str] = None # "16:9", "9:16", "1:1", "4:3", "3:4"
|
||||
imageSize: Optional[str] = None # "2k", "4k"
|
||||
|
||||
|
||||
class GenerationConfigParam(BaseModel):
|
||||
"""Gemini generationConfig parameters (for model name resolution)"""
|
||||
|
||||
responseModalities: Optional[List[str]] = None # ["IMAGE", "TEXT"]
|
||||
imageConfig: Optional[ImageConfig] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
"""Chat completion request (OpenAI compatible)"""
|
||||
"""Chat completion request (OpenAI compatible + Gemini extension)"""
|
||||
|
||||
model: str
|
||||
messages: List[ChatMessage]
|
||||
stream: bool = False
|
||||
@@ -202,3 +234,9 @@ class ChatCompletionRequest(BaseModel):
|
||||
# Flow2API specific parameters
|
||||
image: Optional[str] = None # Base64 encoded image (deprecated, use messages)
|
||||
video: Optional[str] = None # Base64 encoded video (deprecated)
|
||||
# Gemini extension parameters (from extra_body or top-level)
|
||||
generationConfig: Optional[GenerationConfigParam] = None
|
||||
contents: Optional[List[Any]] = None # Gemini native contents
|
||||
|
||||
class Config:
|
||||
extra = "allow" # Allow extra fields like extra_body passthrough
|
||||
|
||||
Reference in New Issue
Block a user