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:
parent
d69ca24085
commit
e9d0bfc62d
@ -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/method,task_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"})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user