From e9d0bfc62d346f01e1f41468d5d104f8e4eba2ab Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 5 Sep 2026 22:33:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(platform):=20=E9=80=82=E9=85=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E6=8C=89=E6=96=87=E6=A1=A3=E7=A4=BA=E4=BE=8B=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E7=94=9F=E6=88=90(=E6=9B=BF=E4=BB=A3=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E9=AA=A8=E6=9E=B6)=E2=80=94=E2=80=94t2v?= =?UTF-8?q?=E6=9B=BE=E5=9B=A0=E6=A8=A1=E6=9D=BF=E7=A1=AC=E5=A1=9Eparams.xx?= =?UTF-8?q?x=5Ffile=E8=BF=90=E8=A1=8C=E6=97=B6=E5=B4=A9;request=5Fexample?= =?UTF-8?q?=E9=80=90=E5=AD=97=E9=A9=B1=E5=8A=A8(model/prompt/=E5=AA=92?= =?UTF-8?q?=E4=BD=93/=E4=B8=9A=E5=8A=A1=E5=8F=82=E6=95=B0=E5=B8=A6?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=BB=98=E8=AE=A4=E5=80=BC);=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BA=A7=E7=89=A9=E5=AD=97=E6=AE=B5=E9=80=92=E5=BD=92?= =?UTF-8?q?=E5=8F=96response=5Fexample(output.video=5Furl);headers?= =?UTF-8?q?=E6=8C=89=E6=96=87=E6=A1=A3=E5=B8=A6X-DashScope-Async;=E8=90=BD?= =?UTF-8?q?=E5=BA=93=E5=89=8D=E5=B9=B2=E8=B7=91=E6=B8=B2=E6=9F=93=E8=87=AA?= =?UTF-8?q?=E6=A3=80(StrictUndefined)=E5=BD=93=E5=9C=BA=E6=8B=A6=E6=88=AA;?= =?UTF-8?q?=E6=97=A7=E9=AA=A8=E6=9E=B6=E6=A8=A1=E6=9D=BF(=E5=90=ABxxx=5Ffi?= =?UTF-8?q?le/=5F=5Ffrom=5Fdoc=5F=5F)=E5=BC=83=E7=94=A8=E8=87=AA=E6=84=88?= =?UTF-8?q?=E9=87=8D=E5=BB=BA;=E6=9F=A5=E8=AF=A2=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=BE=9B=E5=BA=94=E5=95=86=E7=BA=A7=E5=A4=8D?= =?UTF-8?q?=E7=94=A8(protocol-query-path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pipeline_platform/platform_ability.py | 397 ++++++++++++++++++++++---- 1 file changed, 336 insertions(+), 61 deletions(-) diff --git a/pipeline_platform/platform_ability.py b/pipeline_platform/platform_ability.py index 9074faa..499fde5 100644 --- a/pipeline_platform/platform_ability.py +++ b/pipeline_platform/platform_ability.py @@ -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._file)|tojson }} + 其他标量 → {{ params.|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"})