From 32284574ed446d56f02ea6b242b45802c284fd7a Mon Sep 17 00:00:00 2001 From: TheSmallHanCat <234438803@qq.com> Date: Sat, 14 Mar 2026 12:12:06 +0800 Subject: [PATCH] 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. --- src/api/routes.py | 117 +++++++++---- src/core/model_resolver.py | 330 +++++++++++++++++++++++++++++++++++++ src/core/models.py | 40 ++++- 3 files changed, 456 insertions(+), 31 deletions(-) create mode 100644 src/core/model_resolver.py diff --git a/src/api/routes.py b/src/api/routes.py index ec79e11..d243d0f 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -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 diff --git a/src/core/model_resolver.py b/src/core/model_resolver.py new file mode 100644 index 0000000..1c0b925 --- /dev/null +++ b/src/core/model_resolver.py @@ -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 diff --git a/src/core/models.py b/src/core/models.py index 656e71a..8b39230 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -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