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:
TheSmallHanCat
2026-03-14 12:12:06 +08:00
parent d4235256dd
commit 32284574ed
3 changed files with 456 additions and 31 deletions

View File

@@ -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
View 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

View File

@@ -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