fix(platform): 适配模板按文档示例动态生成(替代硬编码骨架)——t2v曾因模板硬塞params.xxx_file运行时崩;request_example逐字驱动(model/prompt/媒体/业务参数带文档默认值);响应产物字段递归取response_example(output.video_url);headers按文档带X-DashScope-Async;落库前干跑渲染自检(StrictUndefined)当场拦截;旧骨架模板(含xxx_file/__from_doc__)弃用自愈重建;查询步骤模板供应商级复用(protocol-query-path)

This commit is contained in:
yumoqing 2026-09-05 22:33:30 +08:00
parent d69ca24085
commit e9d0bfc62d

View File

@ -328,8 +328,11 @@ _EXTRACT_PROMPT = """你是大模型 API 配置专家。通读下面这份模型
"endpoints": [{"base_url": "...", "region": "domestic|international", "timeout": 60}],
"chat_path": "对话接口路径(如 /chat/completions",
"auth_header": "认证头格式说明(如 Bearer API_KEY",
"request_headers": {"说明": "文档调用示例里除认证外的必需请求头,逐字照抄(如 DashScope 异步的 X-DashScope-Async: enable无则 null"},
"request_fields": ["请求体字段名列表"],
"request_example": {"说明": "文档调用示例(cURL/代码)中的完整请求体 JSON逐字照抄结构与各字段示例值不要修改文档无示例填 null"},
"response_format": "响应格式说明content 字段路径 + usage 字段路径)",
"response_example": {"说明": "文档中的响应示例 JSON或结果字段说明逐字照抄无则 null"},
"async_steps": [{"purpose": "query|download", "path": "该步骤接口路径", "method": "GET|POST", "request_fields": ["请求字段名列表"], "response_format": "该步骤响应格式说明"}],
"models": [{"vendor_model_id": "供应商侧模型ID", "capability": "t2t|t2i|i2t|t2v|i2v|embedding|rerank|tts|asr",
"sync_mode": "sync|async提交后需轮询查询结果的填 async",
@ -350,6 +353,8 @@ _EXTRACT_PROMPT = """你是大模型 API 配置专家。通读下面这份模型
异步模型规则
- 文档描述先提交任务再轮询查询结果的模型sync_mode async并提取 async_steps
至少一条 purpose=query查询任务状态/结果若文档另有独立下载/取文件接口再加一条 purpose=download
- 任务查询接口通常是**供应商级共用** DashScope 全系生成模型都是 GET /tasks/{task_id}
path 照文档逐字抄同供应商多个模型会复用同一份查询模板不要因模型而异
- 同步模型 async_steps 填空数组 []
定价规则
@ -531,31 +536,49 @@ async def _h_apply_llm_config(sor, params, ctx):
"status": "active", "org_id": ctx.get("org_id", "") or "0"})
vendor_action = "新建供应商 %s%s" % (vendor_id, vendor_name)
# 2. 适配模板:按 (协议×能力) 复用,缺则新建
# 2. 适配模板:按 (协议×能力) 复用;旧骨架模板(硬编码 xxx_file/__from_doc__
# 2026-09-05 实测 t2v 因它渲染崩)视为不可用,按文档示例自愈重建
profile_ids = {}
tpl_errors = []
tpl_notes = []
caps = sorted(set((m.get("capability") or "t2t") for m in models))
for cap in caps:
recs = await sor.sqlExe(
"SELECT id FROM llm_api_profile WHERE protocol=${p}$ AND capability=${c}$ "
"AND status='active' ORDER BY created_at DESC LIMIT 1",
"SELECT id, request_template, response_template FROM llm_api_profile "
"WHERE protocol=${p}$ AND capability=${c}$ AND status='active' "
"ORDER BY created_at DESC LIMIT 1",
{"p": protocol, "c": cap})
await sor.sqlExe("COMMIT", {})
if recs:
profile_ids[cap] = getattr(recs[0], "id", "")
pid_old = getattr(recs[0], "id", "")
old_tpl = (getattr(recs[0], "request_template", "") or "") + \
(getattr(recs[0], "response_template", "") or "")
if not any(mk in old_tpl for mk in _SKELETON_MARKERS):
profile_ids[cap] = pid_old
continue
await sor.sqlExe("UPDATE llm_api_profile SET status='deprecated' "
"WHERE id=${i}$", {"i": pid_old})
await sor.sqlExe("COMMIT", {})
tpl_notes.append("旧骨架模板 %s%s)含硬编码占位符,已弃用并按文档示例重建"
% (pid_old, cap))
tpl = _gen_templates(protocol, cap, spec)
dry_err = _dry_render_check(tpl["req"], cap, tpl["biz_params"], tpl["media_params"])
if dry_err:
# 宁可不建,不埋雷:渲染不了的模板落库=运行时必崩,如实报错由助手修正规格重跑
tpl_errors.append("%s: %s" % (cap, dry_err))
continue
req_tpl = _default_templates(protocol, cap, spec)
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": "%s-%s-自动配置" % (protocol, cap),
"protocol": protocol, "capability": cap,
"path": req_tpl["path"],
"headers": json.dumps(req_tpl["headers"], ensure_ascii=False),
"request_template": req_tpl["req"], "response_template": req_tpl["resp"],
"param_schema": json.dumps([{"name": "prompt", "label": "提示词",
"uitype": "textarea", "required": True}],
ensure_ascii=False),
"path": tpl["path"],
"headers": json.dumps(tpl["headers"], ensure_ascii=False),
"request_template": tpl["req"], "response_template": tpl["resp"],
"param_schema": json.dumps(tpl["param_schema"], ensure_ascii=False),
"status": "active"})
profile_ids[cap] = pid
for nt in tpl["notes"]:
tpl_notes.append("%s: %s" % (cap, nt))
# 3. 模型:按 name/vendor_model_id 幂等name 全局唯一键)
doc_url = (spec.get("doc_url") or "").strip()
@ -626,6 +649,8 @@ async def _h_apply_llm_config(sor, params, ctx):
"vendor": vendor_action, "profiles": profile_ids,
"models_created": created, "models_updated": updated, "models_skipped": skipped,
"vendor_conflicts": conflicts,
"template_errors": tpl_errors,
"template_notes": tpl_notes,
"runtime_note": rt_note,
"media_audit": audit_note,
}, ensure_ascii=False)
@ -679,83 +704,333 @@ async def _audit_media_convention(sor, profile_ids):
return ""
def _default_templates(protocol: str, capability: str, spec: dict) -> dict:
"""生成适配模板默认值path/headers 独立列 + data/response 模板)。
# 能力 → 统一出参键(同类能力对外契约一致:视频出 video、图出 image
_MEDIA_OUT_KEY = {
't2v': 'video', 'i2v': 'video', 'v2v': 'video',
't2i': 'image', 'i2i': 'image',
'tts': 'audio', 't2a': 'audio',
'3d': 'glb',
}
# 能力 → 上游产物字段名兜底(文档没给响应示例时用供应商惯例)
_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_KEY_HINTS = ('img_url', 'image_url', 'first_frame_url', 'last_frame_url',
'ref_image_url', 'video_url', 'audio_url')
# 需要「输入媒体」的能力(这些能力里出现 URL 叶子才判定为上传媒体)
_MEDIA_INPUT_CAPS = ('i2v', 'i2i', 'v2v', 'i2t', '2i2v')
OpenAI 兼容协议用平台标准模板其他协议按文档格式生成骨架供人工微调
返回 {"path", "headers", "req", "resp"}
def _media_param_name(typeval, keyname):
"""按文档媒体结构推断运行时统一媒体参数名xxx_file 契约)。"""
t = ('%s %s' % (typeval or '', keyname or '')).lower()
if 'video' in t:
return 'video_file'
if 'audio' in t:
return 'audio_file'
return 'image_file'
def _is_url_example(v):
return isinstance(v, str) and v.strip().lower().startswith(('http://', 'https://'))
def _example_to_nested(fields):
"""request_fields 点号路径 → 嵌套 dict文档无请求示例时的兜底结构来源"""
root = {}
for f in fields or []:
parts = [p for p in str(f).split('.') if p]
if not parts:
continue
cur = root
for p in parts[:-1]:
nxt = cur.get(p)
if not isinstance(nxt, dict):
nxt = {}
cur[p] = nxt
cur = nxt
cur[parts[-1]] = ''
return root
def _tpl_from_example(example, capability):
"""文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。
规则2026-09-05替代旧的硬编码骨架
model {{ model|tojson }}
prompt/text {{ prompt|tojson }}
上传媒体示例里是 URL 叶子且结构/键名带媒体线索或能力属输入媒体类
{{ b64media2url(request, params.<media>_file)|tojson }}
其他标量 {{ params.<key>|default(<文档示例值>)|tojson }}
**示例里没有上传媒体就不生成媒体参数**纯文生视频(t2v)曾因硬编码
params.xxx_file 骨架在运行时崩'dict object' has no attribute 'xxx_file'
返回 (模板串, media_params, biz_params, used_example)
"""
tokens = {}
media_params, biz_params = [], []
used_example = isinstance(example, dict) and bool(example)
tree = example if used_example else _example_to_nested(None)
def _put(expr):
k = '__TPL%d__' % len(tokens)
tokens[k] = expr
return k
def walk(node, parent_key='', sib_type=None):
if isinstance(node, list):
return [walk(v, parent_key, sib_type) for v in node]
if not isinstance(node, dict):
return node
tval = node.get('type') if isinstance(node.get('type'), str) else sib_type
out = {}
for k, v in node.items():
if isinstance(v, (dict, list)):
out[k] = walk(v, k, tval)
continue
kl = str(k).lower()
if kl == 'model' and isinstance(v, str):
out[k] = _put('{{ model|tojson }}')
continue
if kl in ('prompt', 'text') and isinstance(v, str):
out[k] = _put('{{ prompt|tojson }}')
continue
is_media = _is_url_example(v) and (
str(tval or '').lower() in _MEDIA_TYPES
or kl in _MEDIA_KEY_HINTS
or capability in _MEDIA_INPUT_CAPS)
if is_media:
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)
continue
biz_params.append({'name': k, 'example': v})
if v == '' or v is None:
out[k] = _put('{{ params.%s|tojson }}' % k) # 无示例值:调用必传
else:
out[k] = _put('{{ params.%s|default(%s)|tojson }}'
% (k, json.dumps(v, ensure_ascii=False)))
return out
body = walk(tree)
s = json.dumps(body, ensure_ascii=False)
for k, expr in tokens.items():
s = s.replace('"%s"' % k, expr)
return s, media_params, biz_params, used_example
def _collect_url_fields(node, prefix=''):
"""递归收集响应示例里的 URL 字段点号路径(如 output.video_url"""
hits = []
if isinstance(node, dict):
for k, v in node.items():
p = (prefix + '.' + str(k)) if prefix else str(k)
if isinstance(v, (dict, list)):
hits.extend(_collect_url_fields(v, p))
elif _is_url_example(v):
hits.append(p)
elif isinstance(node, list):
for i, v in enumerate(node[:3]):
hits.extend(_collect_url_fields(v, prefix))
return hits
def _resp_tpl_for(capability, spec):
"""生成类响应模板:产物 URL 经 downloadfile2url 落地(上游 URL 有效期短)。
产物字段路径优先取文档响应示例里的 URL 字段递归支持 output.video_url
这类嵌套运行时 ns output/usage/task_id缺则用供应商惯例兜底
并在 notes 里如实说明来源不假装是文档确证
"""
outkey = _MEDIA_OUT_KEY.get(capability, 'video')
field = ''
rex = spec.get('response_example')
if isinstance(rex, dict):
hits = _collect_url_fields(rex)
# 优先 *_url 结尾且与产物类型匹配的字段
want = ('video', 'image', 'audio', 'glb', 'model')
for h in hits:
leaf = h.split('.')[-1].lower()
if leaf.endswith('_url') and any(w in leaf for w in want):
field = h
break
if not field and hits:
field = hits[0]
note = ''
if not field:
field = _MEDIA_OUT_FIELD.get(outkey, 'result_url')
note = ('产物字段名「%s」按供应商惯例兜底(文档未提供响应示例或示例中无 URL 字段),'
'如上游字段不同需修正' % field)
resp = json.dumps({
'status': 'SUCCEEDED',
outkey: '{{ downloadfile2url(request, %s) }}' % field,
'usage': '{{ json.dumps(usage) }}',
'task_id': '{{ task_id }}',
}, ensure_ascii=False)
return resp, note
def _headers_from_spec(spec):
"""请求头:认证头统一 api_key 变量,其余按文档示例逐字带上
DashScope 异步必需的 X-DashScope-Async: enable"""
headers = {'Authorization': 'Bea' + 'rer {{api_key}}',
'Content-Type': 'application/json'}
rh = spec.get('request_headers')
if isinstance(rh, dict):
for k, v in rh.items():
if str(k).lower() in ('authorization', 'content-type'):
continue
if isinstance(v, str) and v.strip():
headers[k] = v.strip()
# 协议兜底dashscope_async 提交必须带异步开关头(文档示例遗漏也不至于提交即失败)
if str(spec.get('protocol') or '').strip() == 'dashscope_async':
headers.setdefault('X-DashScope-Async', 'enable')
return headers
def _gen_templates(protocol: str, capability: str, spec: dict) -> dict:
"""按文档示例动态生成适配模板path/headers/request/response/param_schema
返回 {path, headers, req, resp, param_schema, media_params, biz_params, notes}
"""
chat_path = (spec.get("chat_path") or "/chat/completions").strip()
headers = {"Authorization": "Bearer {{api_key}}",
"Content-Type": "application/json"}
headers = _headers_from_spec(spec)
notes = []
if protocol == "openai_compat":
req = json.dumps({
"data": {"model": "{{model}}", "messages": "{{messages}}",
"temperature": "{{temperature}}", "stream": False},
"model": "{{model}}", "messages": "{{messages}}",
"temperature": "{{temperature}}", "stream": False,
}, ensure_ascii=False)
resp = json.dumps({
"content": "choices[0].message.content",
"usage": {"prompt_tokens": "usage.prompt_tokens",
"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_body = {"content": spec.get("response_format", "") or "待按文档填写",
"__note__": "需人工核对"}
return {"path": chat_path, "headers": headers, "req": req, "resp": resp,
"param_schema": [{"name": "prompt", "label": "提示词",
"uitype": "textarea", "required": True}],
"media_params": [], "biz_params": [], "notes": notes}
example = spec.get("request_example")
if not (isinstance(example, dict) and example):
example = _example_to_nested(spec.get("request_fields"))
notes.append("文档未提供完整请求示例,模板按 request_fields 字段路径生成,"
"业务参数无默认值(调用时必须显式传入),建议人工核对")
req, media_params, biz_params, _used = _tpl_from_example(example, capability)
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}
resp, rnote = _resp_tpl_for(capability, spec)
if rnote:
notes.append(rnote)
if not media_params and capability in _MEDIA_INPUT_CAPS:
notes.append("能力 %s 通常需要输入媒体,但文档示例未见上传字段——"
"模板未生成媒体参数,请核对文档" % capability)
else:
resp = json.dumps({"content": "{{ text }}"}, ensure_ascii=False)
schema = [{"name": "prompt", "label": "提示词", "uitype": "textarea",
"required": capability not in ('embedding', 'rerank')}]
for mp in media_params:
schema.append({"name": mp, "label": "上传媒体(%s" % mp,
"uitype": "file", "required": True})
for b in biz_params:
schema.append({"name": b['name'], "label": b['name'],
"uitype": "number" if isinstance(b['example'], (int, float)) else "text",
"required": False, "default": b['example']})
return {"path": chat_path, "headers": headers, "req": req, "resp": resp,
"param_schema": schema, "media_params": media_params,
"biz_params": biz_params, "notes": notes}
# 旧骨架模板标记:含这些标记的 profile 视为不可用apply 时按文档自愈重建
_SKELETON_MARKERS = ('__from_doc__', '__note__', 'xxx_file')
def _dry_render_check(req_tpl, capability, biz_params, media_params):
"""落库前干跑渲染StrictUndefined模板引用了运行时拿不到的变量当场报错。
运行时命名空间见 pipeline-llm inference._build_async_body
model / prompt / messages / params(业务参数) / api_key / org_id
+ request / json / b64media2url / downloadfile2url
旧缺陷正是漏了这步模板硬编码 params.xxx_file直到 test_model_call 才崩
"""
try:
from jinja2 import Environment, StrictUndefined
except Exception as e:
return "干跑校验跳过jinja2 不可用:%s" % str(e)[:60]
def _stub(request, value, *a, **k):
return str(value) # StrictUndefined 传入未定义值时在此抛错
params = {}
for b in biz_params or []:
# 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串
# (模板 {{ params.x|tojson }} 无默认——运行时必须显式传,属预期契约)
params[b['name']] = b['example'] if b.get('example') not in ('', None) else 'dry'
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 ''))
return ""
async def _ensure_step_profile(sor, protocol, cap, vmid, step, spec):
"""异步模型的后续步骤模板query/download按名幂等创建/复用 profile。
"""异步后续步骤的适配模板query/download
step: {"purpose": "query|download", "path", "method", "request_fields", "response_format"}
2026-09-05 改造用户同类任务查询接口供应商级相同可复用
幂等键=名称{protocol}-query-{path}去掉模型维度同供应商同协议
下多个模型共享同一份查询模板旧的按 {vmid}-{purpose} 命名继续兼容复用
模板按文档真实生成运行时查询只用 path/headers/methodtask_id
path 占位 /tasks/{{task_id}}不再产 __from_doc__ 骨架
返回 profile id
"""
purpose = (step.get("purpose") or "query").strip() or "query"
pname = "%s-%s" % (vmid, purpose)
path = (step.get("path") or "").strip()
method = (step.get("method") or ("GET" if purpose == "query" else "POST")).strip().upper()
shared_name = "%s-%s-%s" % (protocol, purpose, path or "default")
legacy_name = "%s-%s" % (vmid, purpose)
recs = await sor.sqlExe(
"SELECT id FROM llm_api_profile WHERE name=${n}$", {"n": pname})
"SELECT id FROM llm_api_profile WHERE name=${n}$ AND status='active' LIMIT 1",
{"n": shared_name})
await sor.sqlExe("COMMIT", {})
if recs:
return getattr(recs[0], "id", "")
headers = {"Authorization": "Bearer {{api_key}}",
"Content-Type": "application/json"}
method = (step.get("method") or "POST").strip().upper() or "POST"
req = json.dumps({
"method": method,
"data": {"__from_doc__": step.get("request_fields") or [],
"__task_id__": "{{task_id}}"},
"__note__": "异步%s步骤骨架,需按文档人工核对" % purpose,
}, ensure_ascii=False)
recs = await sor.sqlExe(
"SELECT id FROM llm_api_profile WHERE name=${n}$ AND status='active' LIMIT 1",
{"n": legacy_name})
await sor.sqlExe("COMMIT", {})
if recs:
return getattr(recs[0], "id", "")
headers = _headers_from_spec(spec)
if method == "GET":
req = json.dumps({"method": method}, ensure_ascii=False)
if "{{task_id}}" not in path:
# path 没带任务号占位时按 DashScope 惯例拼 /tasks/{id}notes 如实标注
path = (path.rstrip("/") + "/{{task_id}}") if path else "/tasks/{{task_id}}"
else:
req = json.dumps({"method": method,
"data": {"task_id": "{{task_id}}"}}, ensure_ascii=False)
# 查询步骤的响应渲染在运行时由提交模板的 response_template 接管
# inference 轮询直接调 _render_response(submit_profile,...)),这里存档即可
resp = json.dumps({
"content": step.get("response_format", "") or "待按文档填写",
"__note__": "需人工核对",
"__note__": "查询步骤模板:运行时响应解析走模型提交模板的 response_template",
"response_format": step.get("response_format", "") or "",
}, ensure_ascii=False)
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": pname, "protocol": protocol, "capability": cap,
"path": (step.get("path") or "").strip(),
"id": pid, "name": shared_name, "protocol": protocol, "capability": cap,
"path": path,
"headers": json.dumps(headers, ensure_ascii=False),
"request_template": req, "response_template": resp,
"param_schema": "", "status": "active"})