diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py index 910b632..2d70c69 100644 --- a/pipeline_platform/platform_ability.py +++ b/pipeline_platform/platform_ability.py @@ -448,13 +448,63 @@ async def _h_apply_llm_config(sor, params, ctx): if protocol not in RUNTIME_PROTOCOLS: rt_note = ("⚠️ 协议「%s」的适配模板已存档,但当前运行时调用链仅支持 %s——" "该供应商模型暂不可被产线直接调用" % (protocol, "/".join(RUNTIME_PROTOCOLS))) + audit_note = await _audit_media_convention(sor, list(profile_ids.values())) return json.dumps({ "vendor": vendor_action, "profiles": profile_ids, "models_created": created, "models_updated": updated, "models_skipped": skipped, "runtime_note": rt_note, + "media_audit": audit_note, }, ensure_ascii=False) +# 生成类能力:出参是文件(图/视频/音频/3D),response 必须 downloadfile2url 落地 +_MEDIA_CAPS = ('t2i', 'i2v', 't2v', 't2a', 'tts', 'i2i', 'v2v', '3d') + + +async def _audit_media_convention(sor, profile_ids): + """强制检查(2026-09-05 用户规则:每个 llm 配置都要检查媒体转换约定): + + 生成类能力的适配模板—— + request_template 含上传媒体参数时,必须用 {{b64media2url(request, xxx_file)}} + 转本地公网 URL 再传上游; + response_template 必须用 {{downloadfile2url(request, <产物url>)}} + 把生成物落地为本地持久 URL(上游 URL 有效期短,视频仅 24 小时)。 + 返回检查结论(无问题为空串),随 apply 结果返回给内部 agent 转告。 + """ + if not profile_ids: + return "" + issues = [] + for pid in profile_ids: + recs = await sor.sqlExe( + "SELECT name, capability, request_template, response_template " + "FROM llm_api_profile WHERE id=${i}$", {"i": pid}) + await sor.sqlExe("COMMIT", {}) + if not recs: + continue + r = recs[0] + cap = getattr(r, 'capability', '') or '' + name = getattr(r, 'name', '') or pid + if cap not in _MEDIA_CAPS: + continue # 非生成类(t2t/embedding/rerank)无生成物 + rt = getattr(r, 'request_template', '') or '' + st = getattr(r, 'response_template', '') or '' + if '__note__' in st and 'downloadfile2url' not in st: + issues.append("「%s」response 模板是骨架——生成物必须用 " + "downloadfile2url(request, <产物url>) 落地后再返回" % name) + elif st and 'downloadfile2url' not in st: + issues.append("「%s」response 模板缺 downloadfile2url——生成类能力" + "(%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', + 'first_frame', 'image_url', 'media')) + if has_upload_ref and 'b64media2url' not in rt: + issues.append("「%s」request 模板含媒体上传参数但缺 b64media2url——" + "上传文件必须转本地公网 URL 再传上游" % name) + if issues: + return "⚠️ 媒体转换约定检查未通过:" + ";".join(issues) + return "" + + def _default_templates(protocol: str, capability: str, spec: dict) -> dict: """生成适配模板默认值(path/headers 独立列 + data/response 模板)。 @@ -475,15 +525,30 @@ def _default_templates(protocol: str, capability: str, spec: dict) -> dict: "completion_tokens": "usage.completion_tokens"}, }, ensure_ascii=False) return {"path": chat_path, "headers": headers, "req": req, "resp": resp} - # 非标准协议:骨架模板(字段路径来自文档提取),标注需人工核对 + # 非标准协议:骨架模板(字段路径来自文档提取),标注需人工核对。 + # 生成类能力(_MEDIA_CAPS)骨架直接带媒体转换约定占位——按用户铁律: + # 上行 b64media2url(上传转公网URL)、下行 downloadfile2url(生成物落地)。 req = json.dumps({ "data": {"__from_doc__": spec.get("request_fields") or []}, "__note__": "非 openai_compat 协议骨架模板,需按文档人工核对", }, ensure_ascii=False) - resp = json.dumps({ - "content": spec.get("response_format", "") or "待按文档填写", - "__note__": "需人工核对", - }, ensure_ascii=False) + resp_body = {"content": spec.get("response_format", "") or "待按文档填写", + "__note__": "需人工核对"} + if capability in _MEDIA_CAPS: + # 生成类:骨架给出落地写法提示,人工只需把 <产物url字段> 换成真实路径 + resp_body = { + "status": "SUCCEEDED", + "video": "{{ downloadfile2url(request, output.video_url) }}", + "__note__": "生成类能力:产物 URL 必须用 downloadfile2url 落地为本地" + "持久 URL(上游仅 24 小时有效);上传媒体在 request 模板用 " + "b64media2url(request, xxx_file);产物字段名按文档核对", + } + req = json.dumps({ + "data": {"__from_doc__": spec.get("request_fields") or [], + "__upload__": "{{ b64media2url(request, params.xxx_file) }}"}, + "__note__": "上传媒体一律 xxx_file 命名 + b64media2url 转公网 URL", + }, ensure_ascii=False) + resp = json.dumps(resp_body, ensure_ascii=False) return {"path": chat_path, "headers": headers, "req": req, "resp": resp}