diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py
index 215d5a0..fc08ffa 100644
--- a/pipeline_platform/platform_ability.py
+++ b/pipeline_platform/platform_ability.py
@@ -766,6 +766,7 @@ async def _audit_media_convention(sor, profile_ids):
"(%s)的产物 URL 必须落地为本地持久 URL" % (name, cap))
# 上传参数线索:模板里出现 image/video/audio 文件参数但没有 b64media2url
has_upload_ref = any(k in rt for k in ('image_file', 'video_file', 'audio_file',
+ 'image_files', 'video_files', 'audio_files',
'first_frame', 'image_url', 'media'))
if has_upload_ref and 'b64media2url' not in rt:
issues.append("「%s」request 模板含媒体上传参数但缺 b64media2url——"
@@ -787,7 +788,8 @@ _MEDIA_OUT_FIELD = {
'video': 'video_url', 'image': 'image_url', 'audio': 'audio_url', 'glb': 'model_url',
}
# 文档示例里表示「上传媒体」的结构线索
-_MEDIA_TYPES = ('first_frame', 'last_frame', 'ref_image', 'ref_images', 'image', 'img')
+_MEDIA_TYPES = ('first_frame', 'last_frame', 'ref_image', 'ref_images', 'image', 'img',
+ 'reference_image', 'reference_video', 'reference_audio', 'reference')
_MEDIA_KEY_HINTS = ('img_url', 'image_url', 'first_frame_url', 'last_frame_url',
'ref_image_url', 'video_url', 'audio_url')
# 需要「输入媒体」的能力(这些能力里出现 URL 叶子才判定为上传媒体)
@@ -795,13 +797,31 @@ _MEDIA_INPUT_CAPS = ('i2v', 'i2i', 'v2v', 'i2t', '2i2v', 'r2v')
def _media_param_name(typeval, keyname):
- """按文档媒体结构推断运行时统一媒体参数名(xxx_file 契约)。"""
+ """按文档媒体结构推断运行时统一媒体参数名。
+
+ 2026-09-06 用户定夺契约(对齐 sage/llmage):媒体统一三数组参数
+ image_files / audio_files / video_files——值是数组或单字符串,
+ 模板 Jinja 动态判断两种形态;有些模型三种任意组合(r2v 最多 9 图),
+ 有些只支持图片,按文档示例动态组装,不固定槽位。
+ """
t = ('%s %s' % (typeval or '', keyname or '')).lower()
if 'video' in t:
- return 'video_file'
+ return 'video_files'
if 'audio' in t:
- return 'audio_file'
- return 'image_file'
+ return 'audio_files'
+ return 'image_files'
+
+
+def _media_list_expr(param):
+ """Jinja 表达式:params. 归一为列表(字符串→单元素;缺省→空列表)。"""
+ return ("(params.{p} if params.{p} is not string and params.{p} "
+ "else ([params.{p}] if params.{p} is string else []))").format(p=param)
+
+
+def _media_scalar_expr(param):
+ """Jinja 表达式:params. 取单值(字符串原样;数组取首个;缺省→空串)。"""
+ return ("(params.{p} if params.{p} is string "
+ "else ((params.{p} or [''])[0]))").format(p=param)
def _is_url_example(v):
@@ -826,14 +846,85 @@ def _example_to_nested(fields):
return root
+def _is_media_array(lst, capability):
+ """判定「媒体数组」(2026-09-06 r2v 教训):文档示例里
+ [{"type": "reference_image", "url": "https://..."}, ...] 这类变长数组
+ (r2v 最多 9 张参考图,有些模型图/视频/音频任意组合)。
+
+ 判据:非空 dict 列表,每个元素都含 URL 叶子且 type/键名带媒体线索
+ (或能力属输入媒体类)。整组换 Jinja 动态组装,**禁止按示例元素个数
+ 展开固定槽位**——那是上一版 r2v 模板渲染崩(image_file)的根因。
+ """
+ if not isinstance(lst, list) or not lst:
+ return False
+ for d in lst:
+ if not isinstance(d, dict):
+ return False
+ tval = str(d.get('type') or '').lower()
+ url_hit = any(_is_url_example(v) for v in d.values())
+ hinted = (tval in _MEDIA_TYPES
+ or any(str(k).lower() in _MEDIA_KEY_HINTS for k in d)
+ or capability in _MEDIA_INPUT_CAPS)
+ if not (url_hit and hinted):
+ return False
+ return True
+
+
+def _media_array_expr(elements):
+ """媒体数组示例 → Jinja 动态组装表达式(三数组契约 image_files/
+ audio_files/video_files,值为字符串或数组均可)。
+
+ 按元素 type/键名归组到统一参数,每组渲染一个 for 循环(元素结构逐字
+ 保留,仅 URL 叶子换 b64media2url);混合类型多循环用 namespace 计数
+ 合并逗号。返回 (expr, media_params)。
+ """
+ groups, seen = [], set()
+ for d in elements:
+ if not isinstance(d, dict):
+ continue
+ tval = d.get('type') if isinstance(d.get('type'), str) else ''
+ urlkey = next((k for k, v in d.items() if _is_url_example(v)), '')
+ if not urlkey:
+ continue
+ mp = _media_param_name(tval, urlkey)
+ if mp in seen:
+ continue
+ seen.add(mp)
+ parts = []
+ for k, v in d.items():
+ if k == urlkey:
+ parts.append('"%s": {{ b64media2url(request, _f)|tojson }}' % k)
+ else:
+ parts.append('%s: %s' % (json.dumps(str(k), ensure_ascii=False),
+ json.dumps(v, ensure_ascii=False)))
+ groups.append((mp, '{%s}' % ', '.join(parts)))
+ if not groups:
+ return '', []
+ params = [g[0] for g in groups]
+ if len(groups) == 1:
+ mp, elem = groups[0]
+ expr = ('[{%% for _f in %s %%}%s{%% if not loop.last %%},'
+ '{%% endif %%}{%% endfor %%}]' % (_media_list_expr(mp), elem))
+ return expr, params
+ segs = ['[{% set _c = namespace(n=0) %}']
+ for mp, elem in groups:
+ segs.append('{%% for _f in %s %%}{%% if _c.n %%},{%% endif %%}'
+ '{%% set _c.n = _c.n + 1 %%}%s{%% endfor %%}'
+ % (_media_list_expr(mp), elem))
+ segs.append(']')
+ return ''.join(segs), params
+
+
def _tpl_from_example(example, capability):
"""文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。
- 规则(2026-09-05,替代旧的硬编码骨架):
+ 规则(2026-09-06 三数组契约版,对齐 sage/llmage):
model 键 → {{ model|tojson }}
prompt/text 键 → {{ prompt|tojson }}
- 上传媒体(示例里是 URL 叶子,且结构/键名带媒体线索或能力属输入媒体类)
- → {{ b64media2url(request, params._file)|tojson }}
+ 媒体数组(r2v media 等变长数组)→ Jinja for 循环按 image_files/
+ audio_files/video_files 动态组装(见 _media_array_expr)
+ 单值上传媒体 → {{ b64media2url(request, )|tojson }}
+ (params.xxx_files 字符串/数组两形态兼容)
其他标量 → {{ params.|default(<文档示例值>)|tojson }}
**示例里没有上传媒体就不生成媒体参数**——纯文生视频(t2v)曾因硬编码
params.xxx_file 骨架在运行时崩('dict object' has no attribute 'xxx_file')。
@@ -852,6 +943,13 @@ def _tpl_from_example(example, capability):
def walk(node, parent_key='', sib_type=None):
if isinstance(node, list):
+ if _is_media_array(node, capability):
+ expr, mps = _media_array_expr(node)
+ if expr:
+ for mp in mps:
+ if mp not in media_params:
+ media_params.append(mp)
+ return _put(expr)
return [walk(v, parent_key, sib_type) for v in node]
if not isinstance(node, dict):
return node
@@ -876,7 +974,8 @@ def _tpl_from_example(example, capability):
mp = _media_param_name(tval, k)
if mp not in media_params:
media_params.append(mp)
- out[k] = _put('{{ b64media2url(request, params.%s)|tojson }}' % mp)
+ out[k] = _put('{{ b64media2url(request, %s)|tojson }}'
+ % _media_scalar_expr(mp))
continue
biz_params.append({'name': k, 'example': v})
if v == '' or v is None:
@@ -1015,7 +1114,10 @@ def _gen_templates(protocol: str, capability: str, spec: dict) -> dict:
# 旧骨架模板标记:含这些标记的 profile 视为不可用,apply 时按文档自愈重建
-_SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file')
+_SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file',
+ # 旧单值媒体契约(2026-09-06 前):params.image_file) 等。
+ # 新三数组契约表达式是 params.image_files if ...,不会误伤。
+ 'params.image_file)', 'params.video_file)', 'params.audio_file)')
def _dry_render_check(req_tpl, capability, biz_params, media_params):
@@ -1034,25 +1136,33 @@ def _dry_render_check(req_tpl, capability, biz_params, media_params):
def _stub(request, value, *a, **k):
return str(value) # StrictUndefined 传入未定义值时在此抛错
- params = {}
+ params_arr = {}
for b in biz_params or []:
# 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串
# (模板 {{ params.x|tojson }} 无默认——运行时必须显式传,属预期契约)
- params[b['name']] = b['example'] if b.get('example') not in ('', None) else 'dry'
+ params_arr[b['name']] = b['example'] if b.get('example') not in ('', None) else 'dry'
+ params_str = dict(params_arr)
for mp in media_params or []:
- params[mp] = 'https://dry-run.invalid/sample.bin'
- ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
- 'params': params, 'api_key': 'sk-dry-run', 'org_id': '0',
- 'request': None, 'json': json,
- 'b64media2url': _stub, 'downloadfile2url': _stub}
- try:
- env = Environment(undefined=StrictUndefined)
- out = env.from_string(req_tpl).render(**ns)
- json.loads(out)
- except Exception as e:
- return ("请求模板干跑渲染失败(落库前拦截):%s: %s——模板引用了运行时不存在的"
- "变量(业务参数可用:%s)" % (type(e).__name__, str(e)[:160],
- sorted(params.keys()) or '无'))
+ # 三数组契约两形态都要干跑(2026-09-06):数组走 for 循环路径,
+ # 字符串走 `is string` 归一路径——模板只兼容其一会在运行时崩
+ params_arr[mp] = ['https://dry-run.invalid/sample.bin']
+ params_str[mp] = 'https://dry-run.invalid/sample.bin'
+ errors = []
+ for tag, params in (('数组形态', params_arr), ('字符串形态', params_str)):
+ ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
+ 'params': params, 'api_key': 'sk-dry-run', 'org_id': '0',
+ 'request': None, 'json': json,
+ 'b64media2url': _stub, 'downloadfile2url': _stub}
+ try:
+ env = Environment(undefined=StrictUndefined)
+ out = env.from_string(req_tpl).render(**ns)
+ json.loads(out)
+ except Exception as e:
+ errors.append("%s渲染失败:%s: %s" % (tag, type(e).__name__, str(e)[:120]))
+ if errors:
+ return ("请求模板干跑渲染失败(落库前拦截):%s——模板引用了运行时不存在的"
+ "变量(业务参数可用:%s)" % (";".join(errors),
+ sorted(params_arr.keys()) or '无'))
return ""
@@ -1330,7 +1440,8 @@ async def _h_test_model_call(sor, params, ctx):
"""真实调用测试:走完整治理链(门禁→上游→结算),返回调用结果+流水ID。
参数:model_name 必填;prompt 文本提示(t2t);业务参数放 params JSON
- (如 image_file/resolution/duration);timeout 等待秒数(异步模型默认 600)。
+ (媒体统一三数组契约 image_files/audio_files/video_files——字符串或数组
+ 均可;另 resolution/duration 等);timeout 等待秒数(异步模型默认 600)。
前置:供应商账号已配 api_key(无 key 会报可行动错误,不静默)。
"""
err = await _require_owner(sor, ctx)
@@ -1548,7 +1659,10 @@ PLATFORM_TOOLS = [
parameters={
"model_name": "模型名(llm_model.name 或 vendor_model_id)",
"prompt": "文本提示词(t2t 默认「回复两个字:正常」;生成类填生成描述)",
- "params": "业务参数 JSON 字符串(生成类模型用,如 {\"image_file\": \"https://...\", \"resolution\": \"480P\", \"duration\": 5})",
+ "params": "业务参数 JSON 字符串(生成类模型用;媒体统一三数组契约 "
+ "image_files/audio_files/video_files,字符串或数组均可,如 "
+ "{\\\"image_files\\\": [\\\"https://...\\\", \\\"https://...\\\"], "
+ "\\\"resolution\\\": \\\"480P\\\", \\\"duration\\\": 5})",
"timeout": "等待秒数(默认600,上限900)",
},
category="platform",
@@ -1586,7 +1700,8 @@ PLATFORM_PROMPT = """
三轮全编造 vendor{}/api_profile{} 等不存在的字段)。**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定)
7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补,
- 不要伪造结果)——生成类模型传 params(image_file/resolution/duration 等)
+ 不要伪造结果)——生成类模型传 params(媒体三数组 image_files/audio_files/
+ video_files + resolution/duration 等)
8. `check_model_accounting` 检查记账(传上一步的 task_ref):
- accounted 且 amount_match=true → 全链通过,汇报金额
- created → 等 60~120 秒再查一次(出账循环 60 秒一轮)
@@ -1610,8 +1725,12 @@ PLATFORM_PROMPT = """
- **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导
- **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL
- 能力分类不存在 → 先加分类(字典/种子/提示词/端点注释四处同步)再配模型,禁止塞近似分类
-- 生成类模型媒体铁律:上行 b64media2url(request, xxx_file)、下行 downloadfile2url(request, url);
- apply 返回的 media_audit 警告必须如实转告
+- 生成类模型媒体铁律:上行统一三数组契约 image_files/audio_files/video_files
+ (字符串或数组均可)经 b64media2url 转公网 URL;下行产物用
+ downloadfile2url(request, url) 落地;apply 返回的 media_audit 警告必须如实转告
+- **模板渲染报错(如 'dict object' has no attribute 'xxx_file')是机制缺陷,
+ 不是要用户去页面手改模板**:如实报告错误原文 + 你判断的根因,由维护者修生成器;
+ 禁止提出「请你在模型治理页面把模板改成…」这类人工兜底方案,禁止把模板 JSON 抄给用户
- 非标协议骨架模板需人工核对的项(__note__)必须如实转告用户,不假装完成
- 文档抓取失败/内容不足时如实说明,不要凭记忆编造 API 格式
- 测试没有 api_key 就停在配置阶段如实汇报,禁止跳过测试谎称完成