pipeline-platform/pipeline_platform/platform_ability.py

2863 lines
154 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
pipeline-service: platform_ability — 平台内部 agent 能力包pipeline_id=platform_general
产线平台自身的运维/管理 agent拥有平台应用和各模块的技能
核心能力是「通读模型 API 文档 → 自动生成模型治理配置(供应商/适配模板/模型/定价)」。
权限模型(代码层硬门禁,不依赖 prompt
所有工具 handler 入口校验调用者持有 owner 组织的角色orgtypeid='owner',含通配 'owner.*')。
内部 agent 只服务 owner 组织的角色——其他组织的用户即使打开页面也调不动任何工具。
配置生成链路(用户给定 API 文档 → 完成配置):
① fetch_model_doc抓取文档页面SSRF 防护:仅 http(s) 公网域名)
② extract_llm_api_specLLM 从文档提取端点/协议/请求响应格式/定价
③ apply_llm_config写库——复用 pipeline-llm 模块的 CRUD 函数
llm_vendor.endpoints + llm_api_profile 模板 + llm_model 含四价)
"""
import asyncio
import hashlib
import json
import logging
import re
import time
import yaml
from appPublic.uniqueID import getID
from pipeline_core import (
ToolDefinition,
PipelineAbility,
register_ability,
)
logger = logging.getLogger("pipeline.platform_ability")
PLATFORM_PIPELINE_ID = "platform_general"
# 组织门禁:内部 agent 仅 owner 组织的角色可操作(任一 owner.* 角色,含通配)
OWNER_ORG = "owner"
# 当前运行时调用链支持的协议(模式定义在模型的适配模板/执行器,非供应商):
# openai_compat —— 同步 chat/completions 一次往返
# dashscope_async —— 异步执行器:提交任务→轮询 query_profile_ids→取结果2026-09-05
# 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。
RUNTIME_PROTOCOLS = ("openai_compat", "dashscope_async", "dashscope_sync")
# ────────────────────── 会话级规格缓存extract 锚定2026-09-05──────────────────────
# 根因apply_llm_config 要求 LLM 把 extract 返回的大 JSON 规格逐字复制进参数,
# 模型「转述」大 JSON 必然编造结构(实测三轮全编造 vendor{}/api_profile{} 等契约外字段)。
# 对策extract 成功后把规格锚定到「会话级缓存」apply/apply_model_pricing 用
# use_last_extract=true 直接取用 + 少量覆盖项vendor_name 等LLM 永不搬运大 JSON。
#
# 会话级隔离(用户硬要求):缓存键 = pipeline_id:user_id:session_id三段缺一不可
# 防跨会话/跨用户/跨产线串数据。存 Redis db4治理命名空间gateway 已在用),
# 跨 worker 进程不丢TTL 兜底过期。Redis 不可用时降级进程内字典(仍按同键隔离)。
_SPEC_CACHE_TTL = 7200 # 规格缓存 2 小时(一次配置会话足够)
_SPEC_CACHE_PREFIX = "llm_spec"
_spec_cache_local = {} # Redis 不可用时的进程内降级(同样按隔离键)
def _spec_cache_key(ctx):
"""会话级隔离键pipeline_id:user_id:session_id。任一段缺失返回 ''(拒绝缓存)。"""
pl = (ctx.get("pipeline_id") or "").strip()
uid = (ctx.get("user_id") or "").strip()
sid = (ctx.get("session_id") or "").strip()
if not (pl and uid and sid):
return ''
return "%s:%s:%s:%s" % (_SPEC_CACHE_PREFIX, pl, uid, sid)
def _spec_redis():
"""复用治理命名空间 Redis db4与 gateway 限流同库,会话态用 db3 不冲突)。"""
try:
import redis as _r
try:
from appPublic.jsonConfig import getConfig
url = (getConfig().website or {}).get('session_redis', {}).get('url', '')
except Exception:
url = ''
if not url:
url = 'redis://127.0.0.1:6379/3'
base = url.rsplit('/', 1)[0]
return _r.Redis.from_url(base + '/4', socket_timeout=2)
except Exception:
return None
def _spec_save(ctx, spec):
"""锚定规格到会话缓存。返回 True/False。无隔离键则不缓存apply 会要求显式传 spec"""
key = _spec_cache_key(ctx)
if not key:
return False
try:
payload = json.dumps(spec, ensure_ascii=False)
except Exception:
return False
r = _spec_redis()
if r is not None:
try:
r.set(key, payload, ex=_SPEC_CACHE_TTL)
return True
except Exception:
pass
_spec_cache_local[key] = (payload, time.time() + _SPEC_CACHE_TTL)
return True
def _spec_load(ctx):
"""取本会话锚定的规格。无则返回 None。严格会话级隔离键含 session_id"""
key = _spec_cache_key(ctx)
if not key:
return None
r = _spec_redis()
raw = None
if r is not None:
try:
v = r.get(key)
raw = v.decode('utf-8') if isinstance(v, (bytes, bytearray)) else v
except Exception:
raw = None
if raw is None:
ent = _spec_cache_local.get(key)
if ent and ent[1] > time.time():
raw = ent[0]
elif ent:
_spec_cache_local.pop(key, None)
if not raw:
return None
try:
return json.loads(raw)
except Exception:
return None
def _spec_overlay(base, overrides):
"""覆盖项合并仅允许少量标量覆盖vendor_name 等),不做大 JSON 搬运。
overrides 只认白名单键,防止 LLM 借覆盖项塞回编造结构。
model_capability2026-09-06 wan3.0 教训):{vendor_model_id: capability}
按模型纠正能力——用户提醒「这是参考生视频」后助手必须有工具路径把纠正
落进规格,否则只能重跑 extract同样提示词同样误判提醒无效的死循环
纠正值仍要过能力字典校验(下游 unknown 拒绝落库),不会被塞私货。
"""
allowed = {'vendor_name', 'base_url', 'protocol', 'doc_url', 'doc_notes'}
out = dict(base or {})
for k in allowed:
if k in (overrides or {}) and str(overrides.get(k) or '').strip():
out[k] = str(overrides[k]).strip()
mc = (overrides or {}).get('model_capability')
if isinstance(mc, str):
try:
mc = json.loads(mc)
except Exception:
mc = None
if isinstance(mc, dict) and mc:
fixed = []
models = list(out.get('models') or [])
for m in models:
vmid = m.get('vendor_model_id') or m.get('name') or ''
newcap = str(mc.get(vmid) or '').strip()
if vmid and newcap and newcap != m.get('capability'):
fixed.append('%s: %s%s' % (vmid, m.get('capability'), newcap))
m['capability'] = newcap
# 人工纠正登记2026-09-08apply 阶段的能力守卫必须跳过
# 这些模型——人工指令是权威,守卫再纠正=把用户的纠正改回去
# qwen-image-plus 实测死循环的机制根源)。
hc = dict(out.get('__human_cap_overrides__') or {})
hc[vmid] = newcap
out['__human_cap_overrides__'] = hc
out['models'] = models
if fixed:
out['doc_notes'] = ((out.get('doc_notes') or '') +
' [能力已按人工纠正: %s]' % ''.join(fixed)).strip()
return out
# ────────────────────── 权限门禁(代码层) ──────────────────────
async def _require_owner(sor, ctx) -> str:
"""校验当前用户持有 owner 组织的角色。返回 ''=通过,否则错误信息。
判定userrole→role JOIN 后,存在任一 orgtypeid='owner' 的角色(含通配 'owner.*')。
"""
uid = ctx.get("user_id", "") or ""
if not uid:
return "无法识别当前用户身份(未登录),内部 agent 仅 owner 组织角色可用"
recs = await sor.sqlExe(
"SELECT r.orgtypeid, r.name FROM userrole ur JOIN role r ON ur.roleid=r.id "
"WHERE ur.userid=${u}$", {"u": uid})
await sor.sqlExe("COMMIT", {})
roles = []
for r in (recs or []):
o = getattr(r, "orgtypeid", "") or ""
n = getattr(r, "name", "") or ""
if o and n:
roles.append("%s.%s" % (o, n))
if any(fn.split(".", 1)[0] == OWNER_ORG for fn in roles):
return ""
return "权限不足:内部 agent 工具仅 %s 组织的角色可操作,当前角色 %s" % (
OWNER_ORG, sorted(set(roles)) or "")
def _row(r):
"""sqlor row → dict容忍属性访问"""
if hasattr(r, "__dict__"):
return {k: v for k, v in r.__dict__.items() if not k.startswith("_")}
return {}
# ────────────────────── 工具 1模型治理状态 ──────────────────────
async def _h_platform_llm_status(sor, params, ctx):
"""供应商/账号/模型/用量概览owner 组织诊断配置用)。"""
err = await _require_owner(sor, ctx)
if err:
return err
out = {}
for tbl in ("llm_vendor", "llm_account", "llm_model"):
recs = await sor.sqlExe(
"SELECT status, COUNT(*) AS c FROM " + tbl + " GROUP BY status", {})
await sor.sqlExe("COMMIT", {})
out[tbl] = {getattr(r, "status", ""): int(getattr(r, "c", 0)) for r in (recs or [])}
accs = await sor.sqlExe(
"SELECT name, balance, status FROM llm_account ORDER BY balance DESC LIMIT 20", {})
await sor.sqlExe("COMMIT", {})
out["accounts"] = [
{"name": getattr(r, "name", ""), "balance": float(getattr(r, "balance", 0) or 0),
"status": getattr(r, "status", "")} for r in (accs or [])]
models = await sor.sqlExe(
"SELECT name, vendor_model_id, capability, status "
"FROM llm_model ORDER BY created_at DESC LIMIT 30", {})
await sor.sqlExe("COMMIT", {})
out["models"] = [
{"name": getattr(r, "name", ""), "vendor_model_id": getattr(r, "vendor_model_id", ""),
"capability": getattr(r, "capability", ""),
"status": getattr(r, "status", "")} for r in (models or [])]
return json.dumps(out, ensure_ascii=False)
# ────────────────────── 工具 2抓取模型 API 文档 ──────────────────────
_MAX_DOC_CHARS = 60000
_MAX_REDIRECTS = 5
def _validate_doc_url(url: str) -> str:
"""SSRF 防护:仅允许 http(s) + 公网域名。返回 ''=通过,否则错误。"""
if not re.match(r"^https?://", url or ""):
return "文档 URL 必须是 http/https 链接"
host = re.sub(r"^https?://", "", url).split("/")[0].split(":")[0].lower()
if not re.match(r"^[a-z0-9][a-z0-9.-]+\.[a-z]{2,}$", host):
return "文档 URL 域名非法(不接受 IP/内网地址)"
if host in ("localhost",) or host.endswith((".local", ".internal", ".localhost")):
return "文档 URL 不允许指向内网/本地地址"
return ""
def _html_to_text(html: str) -> str:
"""粗暴去标签提取文本(文档页面用,不追求完美排版)。"""
txt = re.sub(r"(?is)<(script|style|noscript)[^>]*>.*?</\1>", " ", html or "")
txt = re.sub(r"(?is)<[^>]+>", " ", txt)
txt = re.sub(r"&nbsp;", " ", txt)
txt = re.sub(r"&lt;", "<", txt)
txt = re.sub(r"&gt;", ">", txt)
txt = re.sub(r"&amp;", "&", txt)
txt = re.sub(r"&quot;", '"', txt)
txt = re.sub(r"[ \t]+", " ", txt)
txt = re.sub(r"\n\s*\n+", "\n", txt)
return txt.strip()
def _is_private_ip(ip: str) -> bool:
"""DNS 解析后的二次防线:解析到私有/环回/链路本地地址一律拒绝DNS rebinding 防护)。"""
import ipaddress
try:
a = ipaddress.ip_address(ip)
except ValueError:
return True # 解析不出就当私有(拒绝)
return (a.is_private or a.is_loopback or a.is_link_local
or a.is_reserved or a.is_multicast or a.is_unspecified)
async def _fetch_url_safe(url: str, max_redirects: int = _MAX_REDIRECTS):
"""带重定向逐跳校验的抓取(每一跳都过域名+DNS双重校验
返回 (text, content_type);失败抛 ValueError。
"""
import aiohttp
current = url
for _ in range(max_redirects + 1):
verr = _validate_doc_url(current)
if verr:
raise ValueError(verr)
host = re.sub(r"^https?://", "", current).split("/")[0].split(":")[0].lower()
# DNS 解析校验(同步阻塞短调用,可接受;防 DNS rebinding 指向内网)
try:
infos = await asyncio.get_event_loop().getaddrinfo(host, None)
except Exception as e:
raise ValueError("文档域名无法解析:%s" % str(e)[:120])
for info in infos:
ip = str(info[4][0])
if _is_private_ip(ip):
raise ValueError("文档域名解析到内网地址(%s),拒绝访问" % ip)
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as sess:
async with sess.get(current, headers={"User-Agent": "Mozilla/5.0"},
allow_redirects=False, ssl=False) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location", "")
if not loc:
raise ValueError("重定向缺少 Location")
if loc.startswith("/"):
scheme = "https" if current.startswith("https") else "http"
loc = "%s://%s%s" % (scheme, host, loc)
current = loc
continue
if resp.status != 200:
raise ValueError("抓取失败HTTP %d" % resp.status)
ctype = resp.headers.get("Content-Type", "")
raw = await resp.text(errors="replace")
return raw, ctype
raise ValueError("重定向次数超限(>%d" % max_redirects)
async def _h_fetch_model_doc(sor, params, ctx):
"""抓取模型 API 文档页面 → 纯文本(供 LLM 提取配置规格)。"""
err = await _require_owner(sor, ctx)
if err:
return err
url = (params.get("url") or "").strip()
try:
raw, ctype = await _fetch_url_safe(url)
except ValueError as e:
return str(e)
except Exception as e:
return "抓取失败:%s" % str(e)[:200]
if "html" in ctype.lower() or raw.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
text = _html_to_text(raw)
else:
text = raw
if len(text) > _MAX_DOC_CHARS:
text = text[:_MAX_DOC_CHARS] + "\n[文档过长已截断,共 %d 字符]" % len(text)
# 出处行:提取规格照抄进 doc_url落库写进模型/定价描述2026-09-05 用户规则)
text = text + "\n\n[出处URL] " + url
return text
# ────────────────────── 工具 3提取配置规格LLM ──────────────────────
_EXTRACT_PROMPT = """你是大模型 API 配置专家。通读下面这份模型 API 文档,提取配置规格。
只输出一个 JSON 对象(不要 markdown 代码块),字段:
{
"vendor_name": "供应商名称",
"base_url": "API 基础地址——必须取文档示例请求 URL 中除接口路径外的完整前缀(含 /api/v1 或 /compatible-mode/v1 等路径段,如 https://dashscope.aliyuncs.com/api/v1。禁止只填裸域如 https://dashscope.aliyuncs.com——裸域拼接口路径必 404",
"protocol": "openai_compat | dashscope_sync | dashscope_async | custom能走 /chat/completions 的填 openai_compatDashScope 原生接口按交互形态二选一,见下方协议判定规则)",
"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"},
"field_enums": {"说明": "业务参数的可选值枚举(从文档参数说明表逐字照抄,如 {\"resolution\": [\"480P\",\"720P\",\"1080P\"], \"duration\": [3,5,10]})。文档没列可选值的参数不要编造;无则 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|m2t|t2v|i2v|r2v|i2i|embedding|rerank|tts|asr|mm-embedding|mm-rerank",
"sync_mode": "sync|async提交后需轮询查询结果的填 async",
"description": "一句话说明"}],
"pricing": {
"currency": "CNY|USD文档标注的币种",
"items": [{"vendor_model_id": "对应模型ID",
"factor": "duration|flat|prompt_tokens|completion_tokens|uncache_tokens|cached_tokens计价因子视频按秒=duration按次=flattoken计价文档只给一个输入价=prompt_tokens文档区分缓存命中价=非缓存输入用uncache_tokens+缓存命中用cached_tokens+输出completion_tokens——禁止用prompt_tokens配缓存价会与uncache/cached重复计价",
"unit_price": 0.45,
"unit": "秒|次|百万(文档标注的计价单位名)",
"dimensions": {"resolution": "480P影响价格档位的维度按文档原文值禁止放 model"},
"doc_quote": "文档原文定价句(逐字照抄,标明出处用)"}]
},
"doc_url": "从文档文本末尾的 [出处URL] 行逐字照抄(配置出处,必填)",
"doc_notes": "文档中影响配置的关键注意点"
}
协议判定规则2026-09-08 qwen-image-plus 实测教训:交互形态有三种,同步生成此前被误判成异步):
- **dashscope_sync同步生成**:调用示例是 `MultiModalConversation.call(stream=False)` 或一次 HTTP 请求直接返回产物(图像/语音),无 task_id、无轮询查询接口 → sync_mode=sync、async_steps=[]、**不带 X-DashScope-Async 头**。
- **dashscope_async异步任务**:文档描述「先提交任务拿 task_id、再轮询查询结果」或示例带 `X-DashScope-Async: enable` 头 → sync_mode=async并提取 async_steps
至少一条 purpose=query查询任务状态/结果);若文档另有独立下载/取文件接口,再加一条 purpose=download。
- 判据优先级:**调用示例的交互形态 > 供应商惯例**。同一供应商(如 DashScope可同时有同步生成模型图像/语音)和异步任务模型(长视频)——按各模型自己的文档判,不要按供应商一刀切。
- 任务查询接口通常是**供应商级共用**的(如 DashScope 全系异步模型都是 GET /tasks/{task_id}path 照文档逐字抄;同供应商多个模型会复用同一份查询模板,不要因模型而异。
- 同步模型 async_steps 填空数组 []。
能力判定铁律2026-09-06 wan3.0-video-prime 误判教训——概述明写"四模态全能参考"
输入=音频/图像/文本/视频,却因调用示例只有 prompt 被判成 t2v
- capability 必须以文档**概述正文 + 输入/输出模态声明**为准,禁止以调用示例为准——
示例常只演示最简形态(纯文本 prompt照示例判会把多媒体参考模型误判成纯文本模型。
- 判定表(输入模态 → 输出模态 ⇒ 能力):
多种媒体输入或正文含「参考/reference/全能参考/多图/多模态输入」且输出为视频 ⇒ r2v即使示例只有文本
图像+文本 → 视频 ⇒ i2v仅文本 → 视频 ⇒ t2v仅文本 → 图像 ⇒ t2i图像 → 图像 ⇒ i2i
视频 → 视频 ⇒ v2v 类按字典就近;文本 → 音频 ⇒ tts音频 → 文本 ⇒ asr
仅图像(+文本) → 文本 ⇒ i2t多种媒体输入(图+视频/音频等任意组合)(+文本) → 文本 ⇒ m2t仅文本 → 文本 ⇒ t2t。
- description 里必须保留正文的模态声明原文关键词(如"支持四模态全能参考"),供下游一致性校验。
定价规则:
- pricing.items 每条必须带 doc_quote文档原文定价句逐字照抄——定价只允许来自文档原文
- 价格/单位按文档原文记录,禁止自行换算、推导或统一单位
- 同一模型多档价格(如不同分辨率)拆成多条 item各带自己的 dimensions 与 doc_quote
- dimensions 禁止放 model——一个定价方案只服务一个模型定价完全相同的多个模型
共享同一个定价方案(同一 ppid不是往定价里加 model 过滤
- 文档没写价格items 填空数组 [](禁止编造价格)
文档内容:
"""
async def _h_extract_llm_api_spec(sor, params, ctx):
"""LLM 通读文档文本 → 结构化配置规格JSON"""
err = await _require_owner(sor, ctx)
if err:
return err
doc_text = (params.get("doc_text") or "").strip()
if len(doc_text) < 100:
return "文档文本太短(<100字符无法提取配置——先用 fetch_model_doc 抓取"
try:
from pipeline_service.llm_bridge import llm_call_msgs
# purpose='utility':辅助任务走辅助模型链(机构策略配置则优先,便宜快)
# timeout=300长文档提取慢端点默认 60 秒实测跑不完2026-09-04 根因)
raw = await llm_call_msgs(
[{"role": "system", "content": _EXTRACT_PROMPT},
{"role": "user", "content": doc_text[:48000]}],
temperature=0.1, org_id="0", user_id=ctx.get("user_id", ""),
purpose="utility", timeout=300)
except Exception as e:
return "LLM 提取失败:%s" % str(e)[:200]
txt = (raw or "").strip()
if txt.startswith("```"):
txt = txt.split("\n", 1)[1] if "\n" in txt else txt
txt = txt.rsplit("```", 1)[0]
try:
spec = json.loads(txt)
except Exception:
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return "LLM 输出不是合法 JSON%s" % txt[:300]
try:
spec = json.loads(m.group(0))
except Exception:
return "LLM 输出不是合法 JSON%s" % txt[:300]
# 会话级锚定2026-09-05规格存会话缓存键含 session_id跨会话隔离
# 后续 apply_llm_config / apply_model_pricing 用 use_last_extract=true 取用——
# LLM 不再搬运/转述大 JSON转述必编造结构实测三轮全编造
anchored = _spec_save(ctx, spec)
out = dict(spec)
if anchored:
out["__anchored__"] = ("规格已锚定到本会话缓存。下一步调用 apply_llm_config 与 "
"apply_model_pricing 时传 {\"use_last_extract\": true}"
"需改动只传覆盖项overrides 白名单vendor_name/base_url/"
"protocol/doc_url/doc_notes/model_capability"
"禁止把本规格复制进 spec 参数——复制即编造。")
# 能力一致性告警2026-09-06 wan3.0 教训2026-09-08 升级:词匹配只当触发器,
# 裁决交 LLM——qwen-image-plus 实测词匹配把输出词当输入证据、t2i 被反复改成 i2i
cap_alerts = []
_cap_changed = False
for _m in (spec.get("models") or []):
_fix, _warn = _capability_guard(_m.get("capability", ""), _m.get("description", ""))
if _warn or _fix:
# 词匹配报可疑 → LLM 语义裁决(裁决失败则只告警不纠正)
_jfix, _jnote = await _llm_capability_judge(
_m.get("capability", ""), _m.get("description", ""),
_m.get("vendor_model_id", ""))
if _jfix:
_m["capability"] = _jfix
_cap_changed = True
cap_alerts.append("%s: %s(已按 LLM 裁决更新提取规格)"
% (_m.get("vendor_model_id", "?"), _jnote))
elif _jnote:
cap_alerts.append("%s: %s" % (_m.get("vendor_model_id", "?"), _jnote))
elif _warn:
cap_alerts.append("%s: %s词匹配可疑LLM 裁决维持原判)"
% (_m.get("vendor_model_id", "?"), _warn))
# 裁决改了能力 → 必须重新锚定_spec_save 在裁决前已存旧值,不重存则
# apply(use_last_extract=true) 取回的还是旧能力LLM 裁决白做(实测根因)。
if _cap_changed:
_spec_save(ctx, spec)
if cap_alerts:
out["__capability_alert__"] = ("能力分类存疑项LLM 语义裁决结果):"
+ "".join(cap_alerts)
+ "。如需人工指定用 overrides.model_capability。")
return json.dumps(out, ensure_ascii=False)
# ────────────────────── 工具 4写入配置幂等 ──────────────────────
def _f(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
# 描述里的媒体输入声明词(能力一致性守卫用)
_DESC_MEDIA_WORDS = {
'image': ('图像', '图片', '多图'),
'video': ('视频',),
'audio': ('音频', '声音'),
}
_DESC_REF_WORDS = ('参考生', '全能参考', '参考图', '参考视频', '参考音频', '四模态', '多模态参考')
async def _llm_capability_judge(capability, description, vendor_model_id=''):
"""LLM 语义裁决能力分类2026-09-08 用户定夺:语义判断必须 LLM 参与,
词匹配只能当廉价触发器,不能当裁决者)。
背景缺陷2qwen-image-plus实测——关键词守卫把描述里「图像生成」的
「图像」(输出词)当输入证据,反复把 t2i 纠正成 i2i人工 override 也被
改回去,死循环。词匹配分不清输入/输出语义LLM 能。
分工_capability_guard词匹配只报「可疑」本函数用 utility 廉价模型
按能力字典语义裁决。LLM 失败时返回 (None, 原因)——不裁决就不纠正,
保持提取 LLM 的原判定(宁可保守也不让词匹配越权)。
"""
if not (description or '').strip():
return None, ''
prompt = (
'你是模型能力分类裁决器。模型 %s 的文档描述:\n%s\n\n'
'当前判定的能力分类:%s\n\n'
'能力分类字典(按输入→输出模态判定,描述里的输出媒体词不算输入证据):\n'
't2t=文本→文本i2t=图像→文本m2t=多媒体→文本t2i=文本→图像;'
'i2i=图像→图像t2v=文本→视频i2v=图像→视频r2v=参考媒体(图/视频/音频组合)→视频;'
'tts=文本→语音asr=语音→文本embedding=文本→向量rerank=重排序\n\n'
'请判断当前分类是否与描述的输入/输出模态一致。只返回 JSON'
'{"consistent": true/false, "capability": "正确分类(不一致时给出)", '
'"reason": "一句话依据"}' % (vendor_model_id or '(未命名)', description[:600],
capability or '(空)'))
try:
from pipeline_service.llm_bridge import llm_call_msgs
raw = await llm_call_msgs(
[{"role": "user", "content": prompt}],
temperature=0, org_id="0", purpose="utility", timeout=60)
except Exception as e:
return None, 'LLM 裁决调用失败(保持原判定不纠正): %s' % repr(e)[:120]
txt = (raw or '').strip()
m = re.search(r'\{[\s\S]*\}', txt)
if not m:
return None, 'LLM 裁决输出非 JSON保持原判定不纠正: %s' % txt[:120]
try:
obj = json.loads(m.group(0))
except Exception:
return None, 'LLM 裁决 JSON 解析失败(保持原判定不纠正)'
if not isinstance(obj, dict):
return None, ''
if obj.get('consistent') is True:
return None, ''
newcap = str(obj.get('capability') or '').strip().lower()
reason = str(obj.get('reason') or '')[:150]
if newcap and newcap != (capability or ''):
return newcap, 'LLM 语义裁决 %s%s%s' % (capability, newcap, reason)
return None, ('LLM 裁决认为能力分类存疑但未给出替代(%s),请人工核对'
% reason) if reason else None
def _capability_guard(capability, description):
"""描述模态声明 vs capability 一致性守卫2026-09-06 wan3.0-video-prime 实测:
描述自带"支持四模态全能参考"、capability 却是 t2v——提取 LLM 照调用示例判能力,
示例只演示最简形态就误判提示词加铁律后仍需代码级兜底LLM 不保证遵守)。
保守策略只在「纯文本能力t2v/t2i/t2t+ 描述含媒体输入/参考声明」这种
铁证矛盾时纠正;其他不一致只警告不改(避免误伤描述措辞不规范的正确配置)。
歧义处理:"视频"二字在 t2v/i2v 描述里几乎必现(输出侧),不算输入声明;
输入声明判据 = 参考词(参考生/全能参考/四模态等)或图像/音频词或输入媒体≥2类。
返回 (corrected_cap 或 None, warning 或 None)。
"""
desc = description or ''
if not desc or capability not in ('t2v', 't2i', 't2t'):
return None, None
has_ref = any(w in desc for w in _DESC_REF_WORDS)
kinds = [k for k, words in _DESC_MEDIA_WORDS.items() if any(w in desc for w in words)]
# 输出侧媒体词对称排除2026-09-07 qwen-image-plus 实测根因):纯文本生成
# 能力的描述几乎必含其输出媒体词——t2v 必含「视频」、t2i 必含「图像/图片」,
# 那是输出声明不是输入声明。此前只排 videovideo 在 t2v/i2v 描述必现),
# 没排 imageimage 在 t2i 描述必现)→「千问系列图像生成模型」被误判有
# 图像输入 → t2i 被反复纠正成 i2i且 overrides.model_capability 压不住
# (守卫在 apply 前置阶段每次又改回去,用户纠正陷入死循环)。
_OUTPUT_KIND = {'t2v': 'video', 't2i': 'image'}
out_kind = _OUTPUT_KIND.get(capability)
input_kinds = [k for k in kinds if k != out_kind]
if not (has_ref or input_kinds):
return None, None
if capability == 't2t':
return None, ("描述含媒体输入声明(%s%s)但能力是 t2t——请人工核对能力分类"
% ('/'.join(kinds), ',含参考声明' if has_ref else ''))
out_word = '视频' if capability == 't2v' else '图像'
expected = None
if has_ref:
expected = 'r2v' if capability == 't2v' else 'i2i'
elif len(input_kinds) >= 2:
expected = 'r2v' if capability == 't2v' else 'i2i'
elif 'image' in input_kinds:
expected = 'i2v' if capability == 't2v' else 'i2i'
elif 'audio' in input_kinds:
# 音频输入语义不定r2v 音频参考 / asr / tts 反向),不敢自动纠正
return None, ("描述含音频输入声明但能力是 %s——无法确定目标能力,"
"请人工核对(可用 overrides.model_capability 纠正)" % capability)
if expected and expected != capability:
return expected, ("能力自动纠正 %s%s:描述声明媒体输入(%s%s"
"纯文本能力与之矛盾(提取照示例误判,守卫按正文模态纠正)"
% (capability, expected, '/'.join(kinds),
',含参考声明' if has_ref else ''))
return None, None
# ────────────────────── 模型配套技能自动生成2026-09-08 用户定夺) ──────────────────────
def _model_skill_path(model_name):
"""模型配套技能路径skills/models/{model}/SKILL.md全局模板目录
skills/models/ 不在 SkillLoader 扫描范围global/pipelines/orgs/users
不进 agent 技能目录层——它是 invoke_model 完备性裁决的数据契约,
由 platform_model_tools.load_model_skill 直读。
"""
import os
from pipeline_core.skill_pack import get_skills_base
safe = "".join(ch for ch in (model_name or "") if ch.isalnum() or ch in "-_.")
if not safe:
raise ValueError("模型名不能用于目录: %r" % model_name)
return os.path.join(get_skills_base(), "models", safe, "SKILL.md")
_CAP_MODAL_TEXT = {
"t2t": ("文本", "文本"), "i2t": ("图像", "文本"), "m2t": ("多媒体", "文本"),
"t2i": ("文本", "图像"), "i2i": ("图像", "图像"), "t2v": ("文本", "视频"),
"i2v": ("图像", "视频"), "r2v": ("参考媒体(图像/视频/音频组合)", "视频"),
"tts": ("文本", "语音"), "asr": ("语音", "文本"),
"embedding": ("文本", "向量"), "rerank": ("候选集", "排序"),
}
async def _write_model_skill(sor, vmid, m, spec, profile_ids):
"""生成/更新模型配套技能(幂等:重 apply 覆盖旧版)。
内容全部来自本次落库的确定数据(不编造):
- 能力模态(输入→输出,能力字典语义)
- 必备输入param_schema 里 required=true 的字段 + 能力模态决定的媒体输入)
- 可选参数required=false带默认值
- 调用契约(三数组媒体格式 + 产物形态)
- 出处doc_url / vendor / sync_mode
invoke_model 的完备性裁决 LLM 按本技能对照用户已给内容判断缺什么。
"""
import os
cap = (m.get("capability") or "t2t").strip().lower()
inp, outp = _CAP_MODAL_TEXT.get(cap, ("文本", "产物"))
desc = (m.get("description") or "").strip()
sync_mode = "async" if str(m.get("sync_mode") or "").strip() == "async" else "sync"
doc_url = (spec.get("doc_url") or "").strip()
# param_schema本次生成/复用的模板)→ 必备/可选参数表
required_rows, optional_rows = [], []
pid = profile_ids.get(cap, "")
if pid:
recs = await sor.sqlExe(
"SELECT param_schema FROM llm_api_profile WHERE id=${i}$ LIMIT 1", {"i": pid})
await sor.sqlExe("COMMIT", {})
if recs:
try:
schema = json.loads(getattr(recs[0], "param_schema", "") or "[]")
except Exception:
schema = []
for f in schema if isinstance(schema, list) else []:
if not isinstance(f, dict):
continue
nm = f.get("name") or ""
label = f.get("label") or nm
dv = f.get("defaultvalue")
if f.get("required"):
required_rows.append("- %s%s" % (nm, label))
else:
optional_rows.append("- %s%s%s" % (
nm, label, (",默认 %s" % dv) if dv not in (None, "") else ""))
# 能力模态决定的媒体输入(媒体三数组契约)
media_required = []
if cap in ("i2v", "i2i"):
media_required.append("- image_files输入图片公网 URL 或 base64字符串或数组——必备")
elif cap == "asr":
media_required.append("- audio_files输入音频公网 URL 或 base64——必备")
elif cap == "r2v":
media_required.append("- image_files / audio_files / video_files至少一类参考素材——必备")
elif cap in ("t2v",):
media_required.append("- image_files可选提供参考图则接近 i2v 形态")
lines = [
"---",
"name: model-%s" % vmid,
"description: 模型 %s%s)的输入契约——必备输入/可选参数/媒体格式。invoke_model 完备性裁决依据。" % (vmid, cap),
"capability: %s" % cap,
"sync_mode: %s" % sync_mode,
"---" ,
"",
"# 模型 %s 输入契约" % vmid,
"",
"能力:%s%s%s%s" % (cap, inp, outp, ",异步任务(提交→轮询)" if sync_mode == "async" else ",同步返回"),
]
if desc:
lines += ["", "描述:" + desc[:300]]
lines += ["", "## 必备输入(缺任一即不可调用,须先向用户索取)"]
if media_required:
lines += media_required
if required_rows:
lines += required_rows
if not media_required and not required_rows:
lines.append("- task任务描述/提示词(%s内容)" % inp)
lines += ["", "## 可选参数(有默认值或缺省可接受,缺失**不算**不完备)"]
lines += (optional_rows or ["- (无)"])
lines += [
"",
"## 调用契约",
"- 提示词经 task 传入(组包为 messages 末条文本)。",
"- 媒体输入一律三数组image_files / audio_files / video_files值为公网 URL 或 base64 的字符串或数组)。",
"- 产物:%s(返回本地持久 URL" % outp,
]
if doc_url:
lines.append("- 文档出处:" + doc_url)
lines += [
"",
"## 完备性裁决规则judge_completeness 按此执行)",
"- 对照「必备输入」逐项检查用户已提供的内容(含任务描述里隐含的 URL/素材)。",
"- 必备项缺失 → 判不完备,追问话术说清缺什么、要什么格式。",
"- 可选参数缺失不拦。判断不了放行(宁可放行不误拦)。",
"",
"(本技能由模型上线 apply_llm_config 自动生成,重 apply 时覆盖更新;数据源=落库的 param_schema+能力字典,人工可修订。)",
]
path = _model_skill_path(vmid)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info("模型配套技能已写入: %s", path)
# pipeline_llm 产品类别根名(首次自动建;已存在则复用,防类别树分裂)
_LLM_PRODUCT_ROOT_NAME = "大模型消费"
_LLM_ROOT_CAT_ID = "7S4za5hOEJqBGXb2f-NmK" # 现有根类别 id实测新建时兜底用它
async def _sync_model_products(sor, org_id="0"):
"""模型上线后自动产品映射2026-09-08 用户需求:添加模型时如有产品模块自动映射)。
根治记账 failed「模型未映射到产品」apply 落库模型后,调产品模块的
import_categories_and_products(resource_module='pipeline_llm') 增量导入
所有 active 模型为产品(幂等:已映射的按 resource_ref_id+org_id 跳过,
顺带自愈其他漏映射模型)。
产品模块未装env 无 import_categories_and_products→ 返回跳过说明,
不阻断 apply模型注册本身已成功产品映射是下游增值步骤
返回 (ok, note)。
"""
from ahserver.serverenv import ServerEnv
env = ServerEnv()
import_fn = getattr(env, "import_categories_and_products", None)
if not callable(import_fn):
return True, "产品模块product_management未加载跳过自动产品映射模型已注册成功可后续在产品管理手动导入产线模型"
# 定父类别:复用已有 pipeline_llm 根类别(防类别树分裂);没有则新建根
parent_cat = _LLM_ROOT_CAT_ID
try:
recs = await sor.sqlExe(
"SELECT id FROM product_category WHERE resource_module='pipeline_llm' "
"AND parent_id IS NULL AND org_id=${o}$ LIMIT 1", {"o": org_id})
await sor.sqlExe("COMMIT", {})
if recs:
parent_cat = getattr(recs[0], "id", "") or _LLM_ROOT_CAT_ID
else:
# 查名字兜底parent_id 可能存 '0' 而非 NULL
recs2 = await sor.sqlExe(
"SELECT id FROM product_category WHERE name=${n}$ AND org_id=${o}$ LIMIT 1",
{"n": _LLM_PRODUCT_ROOT_NAME, "o": org_id})
await sor.sqlExe("COMMIT", {})
if recs2:
parent_cat = getattr(recs2[0], "id", "") or _LLM_ROOT_CAT_ID
except Exception as e:
logger.warning("_sync_model_products 父类别解析失败(用默认根): %s", e)
try:
result = await import_fn(
resource_module="pipeline_llm", org_id=org_id,
parent_category_id=parent_cat, user_id="system")
except Exception as e:
return False, "自动产品映射调用失败(模型已注册,请手动在产品管理导入产线模型):%s" % repr(e)[:150]
if not result or not result.get("success"):
err = (result or {}).get("error", "导入无返回") if isinstance(result, dict) else "导入无返回"
return False, "自动产品映射未成功(模型已注册,请手动导入):%s" % str(err)[:150]
return True, "自动产品映射完成:%s" % str((result or {}).get("message", ""))[:150]
async def _h_apply_llm_config(sor, params, ctx):
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型。
幂等:供应商按名称复用;模板按 (协议×能力×形态指纹) 复用——同形态复用/收编,
异形态各建各的(见 _tpl_fingerprint模型按 name/vendor_model_id 复用
name 全局唯一键)——已存在则更新元数据;供应商归属不一致时不改挂、
如实报 vendor_conflicts。
规格来源2026-09-05 防编造改造):
use_last_extract=true推荐→ 取本会话 extract 锚定的规格,
overrides 白名单覆盖vendor_name 等少量标量);
否则用 params.specLLM 手传,历史路径)——结构不符时报错列出期望键。
"""
err = await _require_owner(sor, ctx)
if err:
return err
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
overrides = params.get("overrides") or {}
if isinstance(overrides, str):
try:
overrides = json.loads(overrides)
except Exception:
overrides = {}
spec = None
if use_last:
spec = _spec_load(ctx)
if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec成功后规格自动"
"锚定本会话),再传 {\"use_last_extract\": true}。"
"注意会话级隔离:其他会话提取的规格本会话不可见,需在本会话重新提取。")
spec = _spec_overlay(spec, overrides)
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
# 结构校验修B报错给出路不让模型盲改重试
if not isinstance(spec, dict) or "vendor_name" not in spec or "models" not in spec:
got = sorted(spec.keys())[:12] if isinstance(spec, dict) else "(非对象)"
return ("spec 结构不符——不要自己编写/改写规格结构。期望顶层键:"
"vendor_name / base_url / endpoints / protocol / chat_path / models / "
"pricing / doc_url / doc_notesextract_llm_api_spec 的原样输出)。"
"你传入的顶层键:%s。正确做法:调 extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}(规格已锚定本会话,禁止复制转述),"
"需改动加 overrides仅 vendor_name/base_url/protocol/doc_url/doc_notes"
% got)
vendor_name = (spec.get("vendor_name") or "").strip()
if not vendor_name:
return "spec 缺少 vendor_name可用 overrides.vendor_name 指定)"
endpoints = spec.get("endpoints") or []
if not endpoints and spec.get("base_url"):
endpoints = [{"base_url": spec["base_url"], "region": "domestic", "timeout": 60}]
if not endpoints:
return "spec 缺少 endpoints/base_url"
protocol = (spec.get("protocol") or "openai_compat").strip() or "openai_compat"
models = spec.get("models") or []
if not models:
return "spec.models 为空——文档里没有可配置的模型?"
# 能力一致性守卫前置2026-09-06 wan3.0 二次教训2026-09-08 用户定夺升级):
# 必须在 caps 计算/模板生成之前跑——纠正能力后模板按新能力生成,否则
# profile_ids 无该能力键、模型更新的 IF 空值保护把旧模板原样保留。
# ⚠️ 语义判断必须 LLM 参与:词匹配只当廉价触发器(报可疑),裁决交
# _llm_capability_judge**人工 override 的模型一律跳过守卫**——人工指令是
# 权威,守卫把用户纠正改回去正是 qwen-image-plus 死循环的机制根源。
cap_notes = []
human_caps = spec.get('__human_cap_overrides__') or {}
for m in models:
vmid = (m.get('vendor_model_id') or m.get('name') or '').strip()
if vmid and vmid in human_caps:
cap_notes.append("%s: 能力按人工指定 %s(守卫已跳过,不再自动纠正)"
% (vmid, human_caps[vmid]))
continue
_fix, _warn = _capability_guard(
m.get("capability") or "", m.get("description") or "")
if not (_fix or _warn):
continue
_jfix, _jnote = await _llm_capability_judge(
m.get("capability") or "", m.get("description") or "", vmid)
if _jfix:
m["capability"] = _jfix
cap_notes.append("%s: %s" % (vmid or "?", _jnote))
elif _jnote:
cap_notes.append("%s: %s" % (vmid or "?", _jnote))
else:
cap_notes.append("%s: 词匹配报可疑但 LLM 裁决维持 %s%s"
% (vmid or "?", m.get("capability") or "?",
(_warn or _fix or '')[:80]))
# 能力分类校验2026-09-05 用户定夺的规则):模型能力必须是字典已登记的
# 分类不存在则拒绝落库——先加能力分类appcodes_kv llm_capability
# 含种子/提示词/端点注释四处同步),再配模型。防 LLM 静默塞进近似分类。
recs = await sor.sqlExe(
"SELECT k FROM appcodes_kv WHERE parentid='llm_capability'", {})
await sor.sqlExe("COMMIT", {})
known_caps = set(getattr(r, "k", "") for r in (recs or []))
unknown = sorted(set(
(m.get("capability") or "t2t").strip() for m in models) - known_caps)
if unknown:
return ("能力分类未登记,拒绝落库:%s。请先在能力分类字典"
"appcodes_kv parentid=llm_capability含种子数据/提取提示词/"
"模型列表端点注释同步)中添加该分类,再重新执行本工具。"
"现有分类:%s" % ("".join(unknown), "".join(sorted(known_caps))))
from appPublic.uniqueID import getID
# 1. 供应商按名称复用。端点带协议标签2026-09-05 404 实测教训):
# 同一供应商可同时有 openai_compat(compatible-mode/v1) 与原生异步(api/v1)
# 两类端点,账号绑定/运行时选择必须按协议匹配,否则原生模型被拼到
# compatible-mode 前缀下 → 404。另拦截「裸域退化」提取 LLM 偶发把
# base_url 提成 https://host丢了 /api/v1与已有带路径端点同 host
# 时该裸域无意义,跳过不入目录。
from urllib.parse import urlparse as _urlparse
recs = await sor.sqlExe(
"SELECT id, endpoints FROM llm_vendor WHERE name=${n}$", {"n": vendor_name})
await sor.sqlExe("COMMIT", {})
if recs:
vendor_id = getattr(recs[0], "id", "")
eps_old = []
try:
eps_old = json.loads(getattr(recs[0], "endpoints", "") or "[]")
except Exception:
eps_old = []
merged = list(eps_old)
added_eps = []
skipped_eps = []
for ep in endpoints:
bu = (ep.get("base_url") or "").rstrip("/")
if not bu:
continue
# 裸域退化拦截新增与回填打标两条路径都要拦2026-09-05 e2e 实测:
# 提取 LLM 偶发把 base_url 提成裸域,回填打标会让裸域变「协议匹配」
# 端点被运行时选中 → 拼出 https://host/services/... → 404
pu = _urlparse(bu)
if not pu.path.strip("/"):
same_host = any(_urlparse((e.get("base_url") or "")).netloc == pu.netloc
and _urlparse((e.get("base_url") or "")).path.strip("/")
for e in merged)
if same_host:
skipped_eps.append("%s(裸域退化:同 host 已有带路径端点)" % bu)
continue
# 同 base_url 已存在:只补协议标签(旧端点无 protocol 时回填)
hit = None
for e in merged:
if (e.get("base_url") or "").rstrip("/") == bu:
hit = e
break
if hit is not None:
if protocol and not hit.get("protocol"):
hit["protocol"] = protocol
continue
merged.append({"base_url": bu, "region": ep.get("region") or "domestic",
"timeout": int(_f(ep.get("timeout"), 60)),
"protocol": protocol})
added_eps.append(bu)
await sor.sqlExe(
"UPDATE llm_vendor SET endpoints=${e}$, updated_at=NOW() WHERE id=${i}$",
{"e": json.dumps(merged, ensure_ascii=False), "i": vendor_id})
await sor.sqlExe("COMMIT", {})
vendor_action = "复用供应商 %s(新增端点 %d%s" % (
vendor_id, len(added_eps),
(",跳过 %s" % "".join(skipped_eps)) if skipped_eps else "")
vendor_endpoints = merged
else:
vendor_id = getID()
eps_norm = [{"base_url": (ep.get("base_url") or "").rstrip("/"),
"region": ep.get("region") or "domestic",
"timeout": int(_f(ep.get("timeout"), 60)),
"protocol": protocol} for ep in endpoints
if (ep.get("base_url") or "").strip()]
await sor.C("llm_vendor", {
"id": vendor_id, "name": vendor_name,
"endpoints": json.dumps(eps_norm, ensure_ascii=False),
"description": spec.get("doc_notes", "") or "",
"status": "active", "org_id": ctx.get("org_id", "") or "0"})
vendor_action = "新建供应商 %s%s" % (vendor_id, vendor_name)
vendor_endpoints = eps_norm
# 2. 适配模板:按 (协议×能力×形态指纹) 复用2026-09-06 用户定夺)。
# 纯按能力复用的事故happyhorse r2v 纯图 vs wan3.0 r2v 四模态,共享模板
# 静默丢媒体;一模型一模板则 wan3.1 同 API 时白白新增。指纹同→复用,
# 指纹异→各建各的。旧骨架模板(硬编码 xxx_file/__from_doc__2026-09-05
# 实测 t2v 因它渲染崩)视为不可用,按文档示例自愈重建。
# 旧命名 {protocol}-{cap}-自动配置(无指纹段):形态一致则改名收编,
# 不一致则保留(已挂模型继续可用),新形态另建。
profile_ids = {}
tpl_errors = []
tpl_notes = []
tpl_notes.extend(cap_notes) # 能力守卫纠正/告警先进 notes前置阶段产生
caps = sorted(set((m.get("capability") or "t2t") for m in models))
for cap in caps:
# 先生成再比对指纹来自生成结果path/req/resp/schema 归一化 sha256[:12]
tpl = _gen_templates(protocol, cap, spec)
dry_err = _dry_render_check(tpl["req"], cap, tpl["biz_params"], tpl["media_params"])
if dry_err:
# 宁可不建,不埋雷:渲染不了的模板落库=运行时必崩,如实报错由助手修正规格重跑
# 模型已挂的旧模板不动(更新路径 IF 保护profile_id 保持原值)
tpl_errors.append("%s: %s" % (cap, dry_err))
continue
fp = _tpl_fingerprint(tpl["path"], tpl["req"], tpl["resp"], tpl["param_schema"])
shared_name = "%s-%s-自动配置-%s" % (protocol, cap, fp)
legacy_name = "%s-%s-自动配置" % (protocol, cap)
recs = await sor.sqlExe(
"SELECT id, name, path, request_template, response_template, param_schema "
"FROM llm_api_profile "
"WHERE name IN (${n1}$, ${n2}$) AND status='active' "
"ORDER BY CASE WHEN name=${n1}$ THEN 0 ELSE 1 END LIMIT 1",
{"n1": shared_name, "n2": legacy_name})
await sor.sqlExe("COMMIT", {})
if recs:
pid_old = getattr(recs[0], "id", "")
nm_old = getattr(recs[0], "name", "")
old_tpl = (getattr(recs[0], "request_template", "") or "") + \
(getattr(recs[0], "response_template", "") or "")
# schema 体检:存量脏 schema未注册 uitype/default/重复字段/结构常量)判死重建,
# 否则代码修了、用户前端还是坏表单(自愈门禁,见 _schema_violations
sv = _schema_violations(getattr(recs[0], "param_schema", ""))
for s in sv['soft']:
tpl_notes.append("模板 %s%sschema 提示:%s" % (pid_old, cap, s))
fp_old = _tpl_fingerprint(getattr(recs[0], "path", "") or "",
getattr(recs[0], "request_template", "") or "",
getattr(recs[0], "response_template", "") or "",
getattr(recs[0], "param_schema", "") or "")
broken = bool(sv['hard']) or any(mk in old_tpl for mk in _SKELETON_MARKERS)
if not broken and fp_old == fp:
if nm_old != shared_name:
# 旧命名收编:形态一致,改名挂指纹(存量模型按 id 引用,不受影响)
await sor.sqlExe(
"UPDATE llm_api_profile SET name=${n}$ WHERE id=${i}$",
{"n": shared_name, "i": pid_old})
await sor.sqlExe("COMMIT", {})
tpl_notes.append("模板 %s%s)形态与本次生成一致,旧命名收编为 %s"
% (pid_old, cap, shared_name))
profile_ids[cap] = pid_old
continue
if broken or nm_old == shared_name:
# 坏模板hard 违规/骨架标记)一律弃用——不论命名;
# 指纹命名但内容漂移(人工改过库)的也弃用重建
await sor.sqlExe("UPDATE llm_api_profile SET status='deprecated' "
"WHERE id=${i}$", {"i": pid_old})
await sor.sqlExe("COMMIT", {})
if sv['hard']:
tpl_notes.append("存量模板 %s%sparam_schema 不合规,已弃用重建:%s"
% (pid_old, cap, "".join(sv['hard'])))
elif any(mk in old_tpl for mk in _SKELETON_MARKERS):
tpl_notes.append("旧骨架模板 %s%s)含硬编码占位符,已弃用并按文档示例重建"
% (pid_old, cap))
else:
tpl_notes.append("模板 %s%s)内容与命名指纹不符(漂移),已弃用重建"
% (pid_old, cap))
else:
# 旧命名、健康、但形态不一致:保留(还挂着存量模型,弃用会断运行时),
# 新形态走下面新建
tpl_notes.append("旧命名模板 %s%s)形态与本次生成不一致(指纹 %s%s"
"保留旧模板、按新形态另建" % (pid_old, cap, fp_old, fp))
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": shared_name,
"protocol": protocol, "capability": cap,
"path": tpl["path"],
# method 独立列2026-09-06 用户定夺):提交类模板恒 POST
"method": "POST",
"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()
created, updated, skipped = [], [], []
conflicts = [] # 供应商归属冲突(模型已挂别的供应商)——如实报告不静默迁移
for m in models:
vmid = (m.get("vendor_model_id") or "").strip()
if not vmid:
skipped.append("(缺 vendor_model_id)")
continue
cap = m.get("capability") or "t2t"
sync_mode = "async" if str(m.get("sync_mode") or "").strip() == "async" else "sync"
desc = (m.get("description", "") or "").strip()
# 出处标注2026-09-05 用户规则):模型注册表描述字段必须保存文档 URL
if doc_url and ("[出处:" + doc_url + "]") not in desc:
desc = (desc + " " if desc else "") + "[出处:%s]" % doc_url
# 异步模型:后续步骤模板链(提交后顺序执行 query→[download]
query_ids = []
if sync_mode == "async":
steps = spec.get("async_steps") or []
if not any((s.get("purpose") or "") == "query" for s in steps):
desc = "异步模型但文档未提取到查询步骤query_profile_ids 需人工补录)。" + desc
for s in steps:
qid = await _ensure_step_profile(sor, protocol, cap, vmid, s, spec)
if qid:
query_ids.append(qid)
query_ids_json = json.dumps(query_ids) if query_ids else ""
# 幂等键=nameuk_llm_model_name 全局唯一)。不能按 (vendor_id, vmid) 查:
# 模型换挂供应商后如迁到阿里云百炼旧查询找不到→INSERT→撞唯一键崩实测
recs = await sor.sqlExe(
"SELECT id, vendor_id FROM llm_model WHERE name=${m}$ OR vendor_model_id=${m}$",
{"m": vmid})
await sor.sqlExe("COMMIT", {})
if recs:
mid = getattr(recs[0], "id", "")
cur_vendor = getattr(recs[0], "vendor_id", "") or ""
if cur_vendor and cur_vendor != vendor_id:
# 供应商归属不一致:治理决策,不静默迁移——只更新元数据并如实报告
conflicts.append(
"%s: 模型已存在且挂供应商 id=%s,与本次规格供应商(%s)不一致——"
"未改供应商归属,仅更新描述/同步模式;如需迁移请明确指示"
% (vmid, cur_vendor, vendor_name))
# profile_id 必须一并刷新2026-09-05 实测 bug模型原挂模板可能已被
# 弃用重建(旧骨架自愈),不刷新则运行时仍指 deprecated 模板→必崩
# query_profile_ids 同理要 IF 保护2026-09-06 实测 bug本次规格没带
# async_steps 时空串覆盖会把模型已挂好的查询模板清空 → 异步测试报
# 「未登记查询步骤模板」。空值=没提取到,不是「要清除」。
# capability 必须写回2026-09-06 wan3.0 二次教训):守卫纠正 t2v→r2v
# 后模型行能力不更新=纠正只改了模板没改注册表,运行时仍按旧能力路由
new_pid = profile_ids.get(cap, "")
await sor.sqlExe(
"UPDATE llm_model SET description=${d}$, capability=${cap}$, "
"sync_mode=${sm}$, query_profile_ids=IF(${q}$='', query_profile_ids, ${q}$), "
"profile_id=IF(${p}$='', profile_id, ${p}$), updated_at=NOW() "
"WHERE id=${i}$",
{"d": desc, "cap": cap, "sm": sync_mode, "q": query_ids_json,
"p": new_pid, "i": mid})
await sor.sqlExe("COMMIT", {})
updated.append(vmid)
continue
mid = getID()
await sor.C("llm_model", {
"id": mid, "vendor_id": vendor_id, "account_id": "",
"name": vmid, "vendor_model_id": vmid, "capability": cap,
"sync_mode": sync_mode, "profile_id": profile_ids.get(cap, ""),
"query_profile_ids": query_ids_json,
"ppid": "",
"default_params": "{}", "status": "active",
"description": desc, "org_id": "0"})
created.append(vmid)
rt_note = ""
if protocol not in RUNTIME_PROTOCOLS:
rt_note = ("⚠️ 协议「%s」的适配模板已存档,但当前运行时调用链仅支持 %s——"
"该供应商模型暂不可被产线直接调用" % (protocol, "/".join(RUNTIME_PROTOCOLS)))
# 4. 账号端点绑定自检2026-09-05 404 教训):账号只绑 compatible-mode
# 端点时,原生协议模型运行时会被选到错误端点 → 404。自动补绑本协议
# 端点到该供应商全部活跃账号(不删已有绑定),如实报告。
account_notes = []
proto_idx = [i for i, e in enumerate(vendor_endpoints or [])
if (e.get("protocol") or "") == protocol]
if proto_idx:
recs = await sor.sqlExe(
"SELECT id, name, endpoint_ids FROM llm_account "
"WHERE vendor_id=${v}$ AND status='active'", {"v": vendor_id})
await sor.sqlExe("COMMIT", {})
for acc in recs or []:
try:
bound = [int(b) for b in json.loads(getattr(acc, "endpoint_ids", "") or "[]")]
except Exception:
bound = []
if any(i in proto_idx for i in bound):
continue # 已绑本协议端点,可用
newb = sorted(set(bound) | set(proto_idx))
await sor.sqlExe(
"UPDATE llm_account SET endpoint_ids=${e}$, updated_at=NOW() WHERE id=${i}$",
{"e": json.dumps(newb), "i": getattr(acc, "id", "")})
await sor.sqlExe("COMMIT", {})
account_notes.append("账号「%s」原绑定端点 %s 不含协议 %s 的端点(运行时会 404"
"已自动补绑为 %s" % (getattr(acc, "name", ""), bound,
protocol, newb))
audit_note = await _audit_media_convention(sor, list(profile_ids.values()))
# 5. 模型配套技能自动生成2026-09-08 用户定夺:每个模型的加入需要有
# 配套 skill——记录该模型需要什么样的输入invoke_model 完备性判断由
# LLM 按此技能对照用户已给内容裁决缺什么,不再靠硬编码能力表)。
# 数据全部来自本次落库结果param_schema 的 required + 能力模态 +
# 媒体契约),生成失败只记 note 不阻断(技能缺失时门禁降级为
# 「LLM 按能力语义判断 → 代码表兜底」,功能不失效)。
skill_notes = []
for m in models:
vmid = (m.get("vendor_model_id") or "").strip()
if not vmid:
continue
try:
await _write_model_skill(sor, vmid, m, spec, profile_ids)
skill_notes.append("%s: 配套技能已生成/更新" % vmid)
except Exception as e:
skill_notes.append("%s: 配套技能生成失败(%s)——完备性判断将按能力语义降级"
% (vmid, str(e)[:80]))
# 6. 自动产品映射2026-09-08 用户需求:添加模型时如有产品模块自动做产品映射)。
# 根治记账 failed「模型未映射到产品」——不靠人工事后补。产品模块未装则
# 跳过不阻断(模型注册已成功);映射幂等(已映射跳过+自愈其他漏映射模型)。
prod_ok, prod_note = await _sync_model_products(sor, org_id="0")
return json.dumps({
"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,
"account_notes": account_notes,
"skill_notes": skill_notes,
"product_mapping": {"ok": prod_ok, "note": prod_note},
"runtime_note": rt_note,
"media_audit": audit_note,
}, ensure_ascii=False)
# 生成类能力:出参是文件(图/视频/音频/3Dresponse 必须 downloadfile2url 落地
_MEDIA_CAPS = ('t2i', 'i2v', 't2v', 'r2v', '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',
'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——"
"上传文件必须转本地公网 URL 再传上游" % name)
if issues:
return "⚠️ 媒体转换约定检查未通过:" + "".join(issues)
return ""
# 能力 → 统一出参键(同类能力对外契约一致:视频出 video、图出 image
_MEDIA_OUT_KEY = {
't2v': 'video', 'i2v': 'video', 'v2v': 'video', 'r2v': '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',
'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 叶子才判定为上传媒体)
_MEDIA_INPUT_CAPS = ('i2v', 'i2i', 'v2v', 'i2t', 'm2t', '2i2v', 'r2v')
def _media_param_name(typeval, keyname):
"""按文档媒体结构推断运行时统一媒体参数名。
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_files'
if 'audio' in t:
return 'audio_files'
return 'image_files'
def _media_list_expr(param):
"""Jinja 表达式params.<param> 归一为列表(字符串→单元素;缺省→空列表)。"""
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.<param> 取单值(字符串原样;数组取首个;缺省→空串)。"""
return ("(params.{p} if params.{p} is string "
"else ((params.{p} or [''])[0]))").format(p=param)
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 _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_any_expr(mps):
"""「任一媒体参数有值」Jinja 布尔表达式(字符串/数组两形态归一)。
2026-09-06 用户纠正后引入全能参考模型wan3.0 类 r2v文档明示
「prompt 和 media 必填其一」「文生视频不传入任何媒体文件」——零媒体是
合法 t2v 形态且文档示例**不带 media 键**。无条件渲染会发出 "media": []
空数组API 未承诺接受,不能赌),故 media 键必须条件渲染:有媒体才
输出键,零媒体整个省略。真实示例路径与合成兜底路径共用本表达式。
"""
return ' or '.join(
'(params.%s if params.%s is not string and params.%s else '
'([params.%s] if params.%s is string else []))' % ((mp,) * 5) for mp in mps)
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 _dashscope_envelope(example):
"""DashScope SDK kwargs 形态 → 原生 HTTP 信封归一2026-09-08 实测根因)。
文档常只给 Python SDK 示例MultiModalConversation.call(messages=...,
size=...)),提取 LLM 照抄成平铺 {model, messages, size, watermark...}
但原生 HTTP 端点要求 {"model", "input": {"messages": [...]},
"parameters": {...}}——平铺体提交必 400 InvalidParameter
qwen-image-plus 实测「messages parameter invalid」
代码级确定性归一(不靠提取 LLM 自觉):顶层有 messages 且无 input 信封
messages 进 input除 model/input/parameters 外的业务键全进 parameters。
"""
if not isinstance(example, dict) or not example:
return example
if 'input' in example or 'parameters' in example:
return example # 已是信封形态curl 示例),不动
msgs = example.get('messages')
if not isinstance(msgs, list):
return example # 非 SDK kwargs 形态,不动
out = {'model': example.get('model', '')}
out['input'] = {'messages': msgs}
params = {k: v for k, v in example.items()
if k not in ('model', 'messages')}
if params:
out['parameters'] = params
return out
def _tpl_from_example(example, capability):
"""文档请求示例 → Jinja2 请求体模板(结构逐字保留,值换成运行时变量)。
规则2026-09-06 三数组契约版,对齐 sage/llmage
model 键 → {{ model|tojson }}
prompt/text 键 → {{ prompt|tojson }}
媒体数组r2v media 等变长数组)→ Jinja for 循环按 image_files/
audio_files/video_files 动态组装(见 _media_array_expr
单值上传媒体 → {{ b64media2url(request, <scalar_expr>)|tojson }}
params.xxx_files 字符串/数组两形态兼容)
其他标量 → {{ params.<key>|default(<文档示例值>)|tojson }}
**示例里没有上传媒体就不生成媒体参数**——纯文生视频(t2v)曾因硬编码
params.xxx_file 骨架在运行时崩('dict object' has no attribute 'xxx_file')。
返回 (模板串, media_params, biz_params, used_example)。
"""
tokens = {}
cond_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):
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)
tk = _put(expr)
# media 键条件渲染2026-09-06 用户纠正):零媒体=合法 t2v
# 形态且文档示例不带 media 键,无条件渲染发出 "media": []
# 空数组API 未承诺接受,不能赌)。记录 (token→键名/媒体参数)
# 供替换阶段整键包 Jinja 条件。仅 dict 下的数组键可条件化。
if parent_key:
cond_tokens[tk] = (parent_key, mps)
return tk
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, %s)|tojson }}'
% _media_scalar_expr(mp))
continue
biz_params.append({'name': k, 'example': v})
if v == '' or v is None:
out[k] = _put('{{ params.%s|tojson }}' % k) # 无示例值:调用必传
else:
# 数值强转2026-09-06 真实调用实抓wan3.0 零媒体 t2v 提交
# 报 InvalidParameter "Input should be a valid integer:
# parameters.duration"——params 来自表单/agent 时常为字符串 "5"
# 直接 tojson 出 "5"(带引号)被 API 拒。文档示例是 int/float 的
# 参数按示例类型强转,字符串数字也能落到正确 JSON 类型。
# bool 是 int 子类须先排除bool 不加 |int避免 UI 真值语义被改写)。
coerce = ''
if isinstance(v, bool):
pass
elif isinstance(v, int):
coerce = '|int'
elif isinstance(v, float):
coerce = '|float'
out[k] = _put('{{ (params.%s|default(%s))%s|tojson }}'
% (k, json.dumps(v, ensure_ascii=False), coerce))
return out
body = walk(tree)
s = json.dumps(body, ensure_ascii=False)
for k, expr in tokens.items():
if k in cond_tokens:
# media 键条件渲染:零媒体时整键省略(对齐文档 t2v 形态)。
# json.dumps 分隔符固定 ", ",按键在对象中的位置分三模式吃逗号:
# 首/中键(后随 ", ")→ 条件段自带尾逗号
# 末键(前缀 ", " → 条件段吃掉前逗号
# 独键 → 直接包
keyname, mps = cond_tokens[k]
anyexpr = _media_any_expr(mps)
seg_body = '"%s": %s' % (keyname, expr)
pat_first = '"%s": "%s", ' % (keyname, k)
pat_last = ', "%s": "%s"' % (keyname, k)
pat_only = '"%s": "%s"' % (keyname, k)
if pat_first in s:
s = s.replace(pat_first, ' {%% if %s %%}%s, {%% endif %%}' % (anyexpr, seg_body))
elif pat_last in s:
s = s.replace(pat_last, '{%% if %s %%}, %s{%% endif %%}' % (anyexpr, seg_body))
elif pat_only in s:
s = s.replace(pat_only, ' {%% if %s %%}%s{%% endif %%}' % (anyexpr, seg_body))
continue
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 = ''
proto = str(spec.get('protocol') or '').strip()
if proto == 'dashscope_sync':
# DashScope 同步生成实测响应形态2026-09-08 qwen-image-plus 真图验证):
# output.choices[0].message.content[0].{image|video|audio}
# 同步路径渲染命名空间无 task_id/statusStrictUndefined 会崩),
# 模板只引用 output/usage/request 三个必有变量。
# 优先级:文档响应示例给出的 output.* 产物路径 > 实测兜底形态。
if field and field.startswith('output.'):
path_expr = field
note = '' # 文档确证,无需兜底声明
else:
seg_key = {'image': 'image', 'video': 'video', 'audio': 'audio'}.get(outkey, outkey)
path_expr = 'output.choices[0].message.content[0].%s' % seg_key
if not field:
note = ('产物路径按 DashScope 同步生成实测形态生成(%s'
'文档未提供响应示例,如上游形态不同需修正' % path_expr)
resp = ('{"%s": "{{ downloadfile2url(request, %s) }}", '
'"usage": {{ json.dumps(usage) }}}' % (outkey, path_expr))
return resp, note
if not field:
field = _MEDIA_OUT_FIELD.get(outkey, 'result_url')
note = ('产物字段名「%s」按供应商惯例兜底(文档未提供响应示例或示例中无 URL 字段),'
'如上游字段不同需修正' % field)
# usage 值不加包裹引号2026-09-06 实测 bug`"usage": "{{ json.dumps(usage) }}"`
# 在 usage 非空时内层双引号破坏外层 JSON 字符串值 → json.loads 断在 char 161
# 渲染整体回退、生成物不落地。渲染为嵌套 JSON 对象才是合法形态,手工拼接:
resp = ('{"status": "SUCCEEDED", "%s": "{{ downloadfile2url(request, %s) }}", '
'"usage": {{ json.dumps(usage) }}, "task_id": "{{ task_id }}"}'
% (outkey, field))
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 提交必须带异步开关头(文档示例遗漏也不至于提交即失败);
# dashscope_sync 是同步生成MultiModalConversation stream=False 类,即时返回产物),
# **绝不能带异步头**——带了上游按异步受理,不支持异步的密钥直接 403
# 2026-09-08 qwen-image-plus 实测:'current user api does not support asynchronous calls')。
if str(spec.get('protocol') or '').strip() == 'dashscope_async':
headers.setdefault('X-DashScope-Async', 'enable')
elif str(spec.get('protocol') or '').strip() == 'dashscope_sync':
headers.pop('X-DashScope-Async', None)
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 = _headers_from_spec(spec)
notes = []
if protocol == "openai_compat":
# 2026-09-06 修复(豆包 pro 配置实抓):旧模板用 json.dumps 包占位符——
# 1. "messages": "{{messages}}" 带引号,渲染成 Python repr 字符串而非
# JSON 数组,上游必拒;
# 2. "{{temperature}}" 运行时命名空间无此变量inference._build_upstream_body
# 的 ns 是 model/messages/extra/params/api_key/org_idStrictUndefined
# 干跑必崩 → 落库被拦profiles 恒空,运行时侥幸走 _BUILTIN_REQUEST_TMPL。
# 正确形态对齐 pipeline-llm 内置模板tojson 无引号 + extra 白名单透传循环
# temperature/top_p/max_tokens 等调用方传了才发,不硬编码业务参数)。
req = ('{"model": {{ model|tojson }}, "messages": {{ messages|tojson }}'
'{% for k, v in extra.items() %}, "{{ k }}": {{ v|tojson }}'
'{% endfor %}, "stream": 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,
"param_schema": _build_param_schema(capability, [], []),
"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 字段路径生成,"
"业务参数无默认值(调用时必须显式传入),建议人工核对")
# DashScope SDK kwargs → 原生 HTTP 信封归一2026-09-08 qwen-image-plus 实测:
# 平铺体提交 400 InvalidParameter必须 input.messages + parameters 信封)
if protocol in ('dashscope_sync', 'dashscope_async'):
example = _dashscope_envelope(example)
req, media_params, biz_params, _used = _tpl_from_example(example, capability)
if capability in _MEDIA_CAPS:
resp, rnote = _resp_tpl_for(capability, spec)
if rnote:
notes.append(rnote)
if not media_params and capability in _MEDIA_INPUT_CAPS:
if capability == 'r2v' and (example or {}).get('input') is not None:
# 页面示例是纯文本最简形态SPA 懒加载抓不到完整示例wan3.0-video-prime
# 实测:概述明写"四模态全能参考"、示例只有 input.prompt——r2v 必有
# 参考媒体DashScope 家族契约是 input.media 数组 [{type,url}]
# 类型集 image→reference_image / video→reference_video / audio→reference_audio。
# 复用 _media_array_expre2e 验证过的 namespace 混合类型写法)按契约
# 合成三数组,比只报警更有用:用户填了参考媒体就能发出去。
# 如实注明合成来源,不假装是文档确证。
try:
import re as _re
synth = [{'type': 'reference_image', 'url': 'https://synth.invalid/ref.jpg'},
{'type': 'reference_video', 'url': 'https://synth.invalid/ref.mp4'},
{'type': 'reference_audio', 'url': 'https://synth.invalid/ref.mp3'}]
expr, mps = _media_array_expr(synth)
m = _re.search(r'("input"\s*:\s*\{)', req)
if expr and m:
# 条件渲染2026-09-06 用户纠正后补wan3.0 类全能参考模型
# 文档明示「prompt 和 media 必填其一」「文生视频不传入任何媒体
# 文件」——零媒体是合法 t2v 形态,且文档 t2v 示例**不带 media
# 键**;无条件渲染会发出 "media": [] 空数组API 未承诺接受,
# 不能赌)。有媒体才渲染 media 键,零媒体整个省略。
anyexpr = _media_any_expr(mps)
# 前导空格不可省:段以 {% 开头,紧跟 input 的 { 会拼成
# "{{%" —— Jinja 词法把 {{ 当变量表达式开始 → unexpected '%'
# 2026-09-06 单测实抓。JSON 空白无副作用。
media_seg = (' {%% if %s %%}"media": %s, {%% endif %%}'
% (anyexpr, expr))
pos = m.end()
req = req[:pos] + media_seg + req[pos:]
for mp in mps:
if mp not in media_params:
media_params.append(mp)
notes.append("示例未见参考媒体(页面示例为纯文本最简形态),"
"r2v 契约合成 input.media 数组reference_image/"
"video/audio 三类DashScope 家族契约),零媒体时"
"省略 media 键(对齐文档 t2v 形态)——"
"建议对照供应商 API 参考页人工核对")
except Exception as _e:
notes.append("r2v 媒体合成失败(%s),示例未见上传字段,请人工核对" % str(_e)[:80])
else:
notes.append("能力 %s 通常需要输入媒体,但文档示例未见上传字段——"
"模板未生成媒体参数,请核对文档" % capability)
else:
resp = json.dumps({"content": "{{ text }}"}, ensure_ascii=False)
# field_enums文档参数说明表里的可选值提取层逐字照抄驱动 code 下拉
enums = spec.get("field_enums") if isinstance(spec.get("field_enums"), dict) else None
schema = _build_param_schema(capability, media_params, biz_params, enums)
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',
# 旧单值媒体契约2026-09-06 前params.image_file) 等。
# 新三数组契约表达式是 params.image_files if ...,不会误伤。
'params.image_file)', 'params.video_file)', 'params.audio_file)')
def _tpl_fingerprint(path: str, req: str, resp: str, schema) -> str:
"""模板形态指纹2026-09-06 用户定夺):提交模板复用键=协议×能力×指纹。
为什么不能只按能力复用实测事故happyhorse r2v 纯图参考、wan3.0 r2v
四模态参考(图/视频/音频)——按能力共享一份模板,后配模型的媒体模态被
静默丢弃。也不能一模型一模板wan3.1 若 API 与 wan3.0 完全一致应零新增。
指纹=归一化 (path, request_template, response_template, param_schema) 的
sha256 前 12 位:形态同→复用,形态异→各建各的,无需人工设计签名维度。
归一化规则path 去空白req/resp/schema JSON 往返loads→dumps sorted
紧凑分隔符)消除键序/空白差异;解析失败退化为去空白原文(保守不误判同形)。
headers/method 不入指纹headers 按协议统一,提交模板 method 恒 POST。
"""
def _canon(s):
s = (s or "").strip()
if not s:
return ""
try:
return json.dumps(json.loads(s), sort_keys=True,
ensure_ascii=False, separators=(",", ":"))
except Exception:
return re.sub(r"\s+", "", s)
if not isinstance(schema, str):
schema = json.dumps(schema, sort_keys=True, ensure_ascii=False)
blob = "|".join([(path or "").strip(),
_canon(req), _canon(resp), _canon(schema)])
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
def _schema_violations(raw):
"""存量 param_schema 合规体检2026-09-06 用户指正后补的自愈门禁)。
为什么必须有它apply 的模板复用门禁原先只看 request_template 里的骨架标记,
完全不看 param_schema —— 于是「模板正常但 schema 是 textarea/number/default」的
存量 profile 会被永久复用(实测 happyhorse r2v/t2v/i2v 三个 active profile 全中招,
r2v 还带 3 个重复 type 字段)。代码修好了,用户在前端看到的仍是坏表单:
Input.create 遇未注册 uitype 返回 null 只打一行 debuginput.js:1294字段静默消失。
所以复用前必须体检 schema违规一律弃用重建让存量数据跟着代码自愈。
分级(避免误杀仿权威样例的 profile
hard = 会导致字段消失/撞名覆盖/默认值不生效 → 判死重建
(未注册 uitype、用 default、同名字段重复、结构常量入表单
soft = 功能受限但不崩 → 只提示,不判死。典型:媒体数组缺 multiple:true
input.js:426 决定值是数组还是 files[0];权威样例 minimax_h3_setup.sql:13
的 image_files 就没带 multiple模板靠 for 循环兼容 str故不能判死
返回 {'hard': [...], 'soft': [...]}。
"""
out = {'hard': [], 'soft': []}
raw = (raw or "").strip()
if not raw:
return out
try:
schema = json.loads(raw)
except Exception as exc:
out['hard'].append("param_schema 不是合法 JSON%s" % exc)
return out
if not isinstance(schema, list):
out['hard'].append("param_schema 顶层应为数组")
return out
seen = set()
for f in schema:
if not isinstance(f, dict):
out['hard'].append("schema 元素非对象: %r" % (f,))
continue
nm = f.get("name")
ut = f.get("uitype")
if ut not in _BRICKS_UITYPES:
out['hard'].append("字段 %s 的 uitype %r 未注册" % (nm, ut))
if "default" in f:
out['hard'].append("字段 %s 用了 default应为 defaultvalue" % nm)
# 同名字段重复:前端 dom_element.id 撞名,后者覆盖前者
if nm in seen:
out['hard'].append("字段 %s 重复出现" % nm)
seen.add(nm)
# 结构常量不该出现在表单(值已固化在模板里)
if nm in _STRUCT_CONST_FIELDS:
out['hard'].append("结构常量字段 %s 不该让用户填" % nm)
# 媒体数组参数缺 multipleUI 只能选 1 个soft权威样例也这样
if nm in _MEDIA_UITYPE and not f.get("multiple"):
out['soft'].append("媒体数组字段 %s 缺 multiple:trueUI 只能选 1 个)" % nm)
return out
def _biz_field_uitype(name, example, enums):
"""业务参数 → bricks uitype2026-09-06 用户指正:只准用注册过的类型)。
合法全集bricks/input.js Input.registerstr/hide/tel/date/int/float/check/
checkbox/email/file/image/code/text/password/audio/video/audiorecorder/
audiotext/search/group。**textarea / number 均未注册**,用了前端渲染不出来。
规则:有枚举 → codeUiCode=selectdata:[{value,text}]
数值 → int/float布尔 → check其余 → textUiText 自动增高多行)。
返回 (uitype, extra_opts)。
"""
opts = {}
vals = None
if isinstance(enums, dict):
for k in enums:
if str(k).lower() == str(name).lower():
v = enums[k]
if isinstance(v, (list, tuple)) and v:
vals = list(v)
break
if vals:
opts['data'] = [{'value': v, 'text': ('%s' % v)} for v in vals]
return 'code', opts
if isinstance(example, bool):
return 'check', opts
if isinstance(example, int):
return 'int', opts
if isinstance(example, float):
return 'float', opts
# 文档没给示例值也没枚举:一律 text不是未注册的 textarea/number
return 'text', opts
# 媒体参数 → bricks 专用上传控件UiImage/UiAudio/UiVideo 均继承 UiFile
_MEDIA_UITYPE = {'image_files': 'image', 'audio_files': 'audio', 'video_files': 'video'}
# 媒体参数的中文标签(人话,不做机械拼接)
_MEDIA_LABEL = {'image_files': '参考图片', 'audio_files': '参考音频',
'video_files': '参考视频'}
# bricks 已注册的 uitype 全集input.js 末尾 Input.register 逐行抄录2026-09-06
# 白名单硬校验:写进 param_schema 的类型若不在其中,前端 Input.create 返回 null
# 只打一行 debug 日志input.js:1294字段静默消失——比报错更难查。
_BRICKS_UITYPES = frozenset([
'str', 'hide', 'tel', 'date', 'int', 'float', 'check', 'checkbox', 'email',
'file', 'image', 'code', 'text', 'password', 'audio', 'video',
'audiorecorder', 'audiotext', 'search', 'group',
])
# 文档结构常量值在模板里已逐字固化不该让用户填r2v 的 media[].type 等)
_STRUCT_CONST_FIELDS = frozenset(['type', 'role', 'format', 'mime_type'])
def _validate_uitypes(schema):
"""param_schema uitype 白名单校验(防未注册类型静默丢字段)。
另校验默认值字段名:只准 defaultvalue——input.js 从不读 default
写成 default 的默认值前端不生效2026-09-06 实测踩坑)。
违规直接抛异常,让 apply 当场失败并报进 template_errors不落库坏 schema。
"""
for f in schema:
ut = f.get('uitype')
if ut not in _BRICKS_UITYPES:
raise ValueError('param_schema 非法 uitype %r(字段 %s——bricks 已注册类型: %s'
% (ut, f.get('name'), '/'.join(sorted(_BRICKS_UITYPES))))
if 'default' in f:
raise ValueError('param_schema 字段 %s 用了 default——bricks 只认 defaultvalue'
% f.get('name'))
return True
def _build_param_schema(capability, media_params, biz_params, enums=None):
"""生成 bricks 合规的参数表单 schema。
合规要点2026-09-06对照 bricks/input.js + uapi/sql/minimax_h3_setup.sql 权威样例):
- uitype 只取注册过的值(禁 textarea/number
- 默认值字段名必须是 defaultvalueinput.js 只读它default 无人消费)
- 数组媒体参数带 multiple:trueinput.js:426 决定值是数组还是单文件;
r2v 最多 9 张参考图,缺它 UI 只能选 1 张)
- 媒体 uitype 用专用控件 image/audio/video非泛用 file带预览+相机
- 文档结构常量(如 media 元素的 type: "reference_image")不进表单——
模板里已逐字固化,让用户填只会出错(旧版曾生成 3 个重复 type 字段)
- 同名参数去重(数组多元素曾各自产出一条同名业务参数)
"""
schema = [{'name': 'prompt', 'label': '提示词', 'uitype': 'text',
'required': capability not in ('embedding', 'rerank')}]
seen = {'prompt'}
for mp in media_params:
if mp in seen:
continue
seen.add(mp)
schema.append({'name': mp, 'label': _MEDIA_LABEL.get(mp, mp),
'uitype': _MEDIA_UITYPE.get(mp, 'file'),
# 媒体一律非必填2026-09-06 用户纠正):全能参考模型(wan3.0)
# 三类媒体可自由组合、且允许全不传——全不传即纯文生视频(t2v)。
# 文档明示「prompt 和 media 必填其一」「文生视频仅通过 prompt
# 不传入任何媒体文件」。required=True 会把合法的 t2v / 部分组合
# 模式全堵死。prompt 保持必填,作「必填其一」的锚。
# 纯参考模型(无 t2v 模式)若空媒体提交API 会拒、test_model_call
# 当场暴露,非静默失败——故 UI 层一律非必填是安全默认。
'required': False,
# 三数组契约UI 允许多选,值成数组;运行时兼容字符串/数组两形态
'multiple': True})
for b in biz_params:
name = b['name']
# 结构常量不进表单:媒体元素的 type 等已在模板里逐字固化
if name in seen or str(name).lower() in _STRUCT_CONST_FIELDS:
continue
seen.add(name)
uitype, extra = _biz_field_uitype(name, b.get('example'), enums)
f = {'name': name, 'label': b.get('label') or name,
'uitype': uitype, 'required': False}
f.update(extra)
if b.get('example') not in (None, ''):
f['defaultvalue'] = b['example']
schema.append(f)
_validate_uitypes(schema)
return schema
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_arr = {}
for b in biz_params or []:
# 干跑模拟「业务参数全部提供」场景:无示例值的参数给占位串
# (模板 {{ params.x|tojson }} 无默认——运行时必须显式传,属预期契约)
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 []:
# 三数组契约两形态都要干跑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),
# 零媒体形态2026-09-06全能参考模型(r2v)文档明示
# 「prompt 和 media 必填其一」,零媒体=合法 t2v 模式且
# 文档示例不带 media 键——模板条件渲染的 False 分支
# 必须干跑出合法 JSON省略 media 键,非空数组)。
# 只测空数组 []运行时真实「未选媒体」场景schema 字段
# 存在值为空);不测缺键——运行时用默认 Undefined 容错,
# StrictUndefined 下缺键会误报,二者行为不一致。
('零媒体形态', dict(params_arr, **{mp: [] for mp in media_params or []}))):
ns = {'model': 'dry-run-model', 'prompt': '干跑校验', 'messages': [],
# extra运行时白名单透传参数inference._build_upstream_body 注入,
# openai_compat 模板的 for 循环消费)——干跑缺它会误报 UndefinedError
'extra': {'temperature': 0.7},
'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 ""
async def _ensure_step_profile(sor, protocol, cap, vmid, step, spec):
"""异步后续步骤的适配模板query/download
2026-09-05 改造(用户:同类任务查询接口供应商级相同,可复用):
幂等键=名称「{protocol}-{purpose}-{path}」(去掉模型维度)——同供应商同协议
下多个模型共享同一份查询模板;旧的 {vmid}-{purpose} 命名兼容复用,
但若旧模板仍是骨架__from_doc__同样弃用重建。
path 归一化(对齐运行时契约,实测踩过三个坑):
- 任务号占位符用**单花括号** {task_id}(运行时 str.replace 消费,
Jinja 双花括号不会被替换);文档给 {{task_id}} 也归一为单花括号
- 剥掉 base_url 已有的路径前缀(提取给 /api/v1/tasks/{task_id}
运行时 url=base_url+pathbase_url 已含 /api/v1 → 不归一会双前缀 404
- 缺占位符时按 DashScope 惯例补 /tasks/{task_id}
返回 profile id。
"""
purpose = (step.get("purpose") or "query").strip() or "query"
path = (step.get("path") or "").strip()
method = (step.get("method") or ("GET" if purpose == "query" else "POST")).strip().upper()
# path 归一化
from urllib.parse import urlparse
bu_path = urlparse((spec.get("base_url") or "").strip()).path.rstrip("/")
if bu_path and path.startswith(bu_path + "/"):
path = path[len(bu_path):]
path = path.replace("{{task_id}}", "{task_id}")
if "{task_id}" not in path and purpose == "query":
path = (path.rstrip("/") + "/{task_id}") if path else "/tasks/{task_id}"
shared_name = "%s-%s-%s" % (protocol, purpose, path or "default")
legacy_name = "%s-%s" % (vmid, purpose)
for cand in (shared_name, legacy_name):
recs = await sor.sqlExe(
"SELECT id, request_template, response_template FROM llm_api_profile "
"WHERE name=${n}$ AND status='active' LIMIT 1", {"n": cand})
await sor.sqlExe("COMMIT", {})
if not recs:
continue
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):
return getattr(recs[0], "id", "")
pid_old = getattr(recs[0], "id", "")
await sor.sqlExe("UPDATE llm_api_profile SET status='deprecated' "
"WHERE id=${i}$", {"i": pid_old})
await sor.sqlExe("COMMIT", {})
# 旧骨架弃用——继续往下重建(不 return
headers = _headers_from_spec(spec)
# method 独立列2026-09-06 用户定夺):不再塞 request_template JSON
if method == "GET":
req = ""
else:
req = json.dumps({"data": {"task_id": "{task_id}"}}, ensure_ascii=False)
# 查询步骤的响应解析在运行时由提交模板的 response_template 接管
# inference._async_inference 轮询后直接用提交模板渲染),这里存档即可
resp = json.dumps({
"note": "查询步骤模板:运行时响应解析走模型提交模板的 response_template",
"response_format": step.get("response_format", "") or "",
}, ensure_ascii=False)
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": shared_name, "protocol": protocol, "capability": cap,
"path": path, "method": method,
"headers": json.dumps(headers, ensure_ascii=False),
"request_template": req, "response_template": resp,
"param_schema": "", "status": "active"})
return pid
# ────────────────────── 工具 5定价自动导入 ──────────────────────
_FACTOR_LABELS = {
'duration': '时长', 'flat': '按次', 'prompt_tokens': '输入tokens',
'completion_tokens': '输出tokens', 'uncache_tokens': '非缓存输入tokens',
'cached_tokens': '缓存tokens',
}
# 缓存拆分因子derived 自 usage 原始字段,定价引擎自动计算,见 pricing-data-format 技能)
_DERIVED_FACTOR_SPECS = {
'uncache_tokens': {
'type': 'int', 'role': 'factor', 'label': '非缓存输入Token',
'derived': 'prompt_tokens - prompt_tokens_details.cached_tokens'},
'cached_tokens': {
'type': 'int', 'role': 'factor', 'label': '缓存Token',
'derived': 'prompt_tokens_details.cached_tokens'},
}
# token 因子族:一个 token 模型的定价天然多因子(输入/输出/缓存同在一个方案)。
# 旧校验「同模型仅允许单一因子」与生产事实矛盾qwen3.8-max 三因子同方案正常出账),
# token 模型按此族合并为一个定价方案2026-09-06 修复)。
_TOKEN_FACTORS = ('prompt_tokens', 'completion_tokens', 'uncache_tokens', 'cached_tokens')
# 同一因子多条价格且无维度区分 → 引擎会重复计价,必须拆方案(真混用的判据)
def _check_factor_dup(mitems, factors):
seen = set()
for it in mitems:
f = it.get("factor") or "flat"
dims = json.dumps(it.get("dimensions") or {}, ensure_ascii=False, sort_keys=True)
k = (f, dims)
if k in seen:
return ("因子 %s 出现多条相同维度的价格(引擎会重复计价)——请核对文档,"
"同因子多价必须用维度区分档位" % f)
seen.add(k)
return None
def _build_pricing_yaml(currency_items, dimensions_all, factors):
"""生成定价 YAML2026-09-05 用户定夺的新模式2026-09-06 支持多因子):
- 不放 model 过滤:一个定价方案只服务一个模型;定价相同的多个模型共享
同一 ppid不是往定价里加 model 维度)
- 不用 filters 子结构:维度直接平铺在定价项(引擎对非保留键做 AND 匹配)
- fields 需定义每个维度role: filter否则引擎报「在fields中没有定义」
- factors 为列表token 模型天然多因子(输入/输出/缓存同在一个方案,
生产实证 qwen3.8-max每条 pricing 用各自的 factorderived 因子
uncache_tokens/cached_tokens必须在 fields 定义 derived 表达式,
引擎计算衍生字段依赖此定义pricing-data-format 技能 §2
"""
fields = {
'price_factors': {'type': 'string', 'role': 'factor', 'label': '计价因子'},
'unit_prices': {'type': 'float', 'role': 'factor', 'label': '单位定价'},
'unit': {'type': 'string', 'role': 'factor', 'label': '计价单位'},
}
for factor in factors:
if factor in fields:
continue
if factor in _DERIVED_FACTOR_SPECS:
fields[factor] = dict(_DERIVED_FACTOR_SPECS[factor])
else:
fields[factor] = {'type': 'float', 'role': 'factor',
'label': _FACTOR_LABELS.get(factor, factor)}
for dim in sorted(dimensions_all):
fields[dim] = {'type': 'string', 'role': 'filter', 'label': dim}
pricings = []
for it in currency_items:
item = {'price_factors': it.get('factor') or 'flat',
'unit_prices': it['unit_price'], 'unit': it['unit']}
for dk, dv in sorted((it.get('dimensions') or {}).items()):
item[dk] = dv
pricings.append(item)
units = sorted(set(it['unit'] for it in currency_items))
unit_values = {u: 1 for u in units}
if '百万' in unit_values:
unit_values['百万'] = 1000000
if '' in unit_values:
unit_values[''] = 1000
doc = {'unit_values': unit_values, 'fields': fields, 'pricings': pricings}
return yaml.dump(doc, allow_unicode=True, sort_keys=False)
async def _h_apply_model_pricing(sor, params, ctx):
"""定价自动导入:按提取规格建定价方案并挂模型 ppid幂等
幂等键:模型已有 ppid 且方案描述含同一出处 URL → 更新时序(拉链);
否则新建方案。价格只来自文档原文(提取层强制 doc_quote
规格来源2026-09-05 防编造改造use_last_extract=true → 取本会话
extract 锚定规格(含 pricing/doc_url禁止 LLM 复制转述大 JSON。
"""
err = await _require_owner(sor, ctx)
if err:
return err
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
if use_last:
spec = _spec_load(ctx)
if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec成功后规格自动"
"锚定本会话,含 pricing 与 doc_url再传 {\"use_last_extract\": true}。"
"会话级隔离:其他会话的规格本会话不可见。")
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
if not isinstance(spec, dict) or "pricing" not in spec:
return ("spec 结构不符——不要自己编写规格。正确做法extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}pricing 与 doc_url 已锚定本会话)。")
pricing = spec.get("pricing") or {}
items = pricing.get("items") or []
if not items:
return "spec.pricing.items 为空——文档没提取到价格(禁止编造),如需定价请先核对文档"
doc_url = (spec.get("doc_url") or "").strip()
currency = (pricing.get("currency") or "CNY").strip() or "CNY"
# 校验:每条必须有 doc_quote出处dimensions 禁止 model
for it in items:
if not (it.get("doc_quote") or "").strip():
return "定价条目缺 doc_quote文档原文定价句——定价只允许来自文档原文拒绝落库"
if "model" in (it.get("dimensions") or {}):
return "定价 dimensions 含 model——一个定价方案只服务一个模型禁止 model 维度;定价相同的模型共享同一 ppid"
# 按 vendor_model_id 分组(一个模型一个定价方案)
by_model = {}
for it in items:
vmid = (it.get("vendor_model_id") or "").strip()
if not vmid:
return "定价条目缺 vendor_model_id"
by_model.setdefault(vmid, []).append(it)
results = []
for vmid, mitems in by_model.items():
# 找模型
recs = await sor.sqlExe(
"SELECT id, name, ppid FROM llm_model WHERE vendor_model_id=${m}$ "
"AND status='active' LIMIT 1", {"m": vmid})
await sor.sqlExe("COMMIT", {})
if not recs:
results.append({"model": vmid, "ok": False,
"error": "模型未注册(先 apply_llm_config"})
continue
model_id = getattr(recs[0], "id", "")
old_ppid = getattr(recs[0], "ppid", "") or ""
factors = sorted(set((it.get("factor") or "flat") for it in mitems))
# 因子族校验2026-09-06 修复token 模型天然多因子(输入/输出/缓存
# 同在一个方案,生产实证 qwen3.8-max ppid=TNJnx_Nw1HGDOtDIFji-w 三因子
# 正常出账)——旧「单因子」铁律把所有 token 定价判死,工具校验与生产
# 事实矛盾。真混用的判据改为两条:
# 1. token 族与非 token 族duration/flat 等)不可混在一个方案
# 2. 同一因子多条价格且维度相同 → 引擎重复计价
non_token = sorted(set(factors) - set(_TOKEN_FACTORS))
token_hit = sorted(set(factors) & set(_TOKEN_FACTORS))
if non_token and token_hit:
results.append({"model": vmid, "ok": False,
"error": "token 因子 %s 与按量因子 %s 混在一个模型——"
"计费单位不同,请拆分为多个定价方案" % (token_hit, non_token)})
continue
if len(non_token) > 1:
results.append({"model": vmid, "ok": False,
"error": "同一模型混用多个按量计价因子 %s——请拆分为多个定价方案" % non_token})
continue
# 缓存拆分与整体输入价互斥prompt_tokens 计全部输入uncache_tokens 只计
# 非缓存部分——同方案并存会对缓存命中 tokens 重复计价2026-09-06
if 'prompt_tokens' in factors and ('uncache_tokens' in factors or 'cached_tokens' in factors):
results.append({"model": vmid, "ok": False,
"error": "prompt_tokens 与 uncache_tokens/cached_tokens 同方案——"
"缓存命中部分会被重复计价。文档区分缓存价时只用 "
"uncache_tokens+cached_tokens+completion_tokens"
"不区分时只用 prompt_tokens+completion_tokens"})
continue
dup_err = _check_factor_dup(mitems, factors)
if dup_err:
results.append({"model": vmid, "ok": False, "error": dup_err})
continue
factor = ",".join(factors) # 方案命名/回报展示用
dims_all = set()
for it in mitems:
dims_all.update((it.get("dimensions") or {}).keys())
yaml_str = _build_pricing_yaml(mitems, dims_all, factors)
# 引擎试算护栏:渲染一遍确认合法 YAML + fields 完整(不落库先验证)
try:
parsed = yaml.safe_load(yaml_str)
assert parsed.get('pricings') and parsed.get('fields')
except Exception as e:
results.append({"model": vmid, "ok": False,
"error": "定价 YAML 生成校验失败:%s" % str(e)[:150]})
continue
desc_quotes = "".join((it.get("doc_quote") or "")[:80] for it in mitems)[:500]
pp_desc = "定价出处:%s | 文档原文:%s" % (doc_url or "(未提供)", desc_quotes)
now = time.strftime('%Y-%m-%d')
if old_ppid:
ppid = old_ppid
# 幂等核心2026-09-05 用户纠正:时序应只有一条有效行,重复执行不得堆历史):
# 先取当前生效行比对内容——
# 内容相同 → 跳过(不产生新时序行)
# 内容不同且生效行今天才启用 → 原地 UPDATE同日替换无历史区间可保留
# 拉链会产生 enabled==expired 的零宽死行,纯垃圾)
# 内容不同且生效行是历史日期 → 正常拉链(关旧行+插新行)
recs2 = await sor.sqlExe(
"SELECT id, pricing_data, enabled_date FROM pricing_program_timing "
"WHERE ppid=${p}$ AND expired_date='9999-12-31' "
"ORDER BY enabled_date DESC LIMIT 1", {"p": ppid})
await sor.sqlExe("COMMIT", {})
cur = recs2[0] if recs2 else None
same = False
if cur is not None:
try:
same = (yaml.safe_load(getattr(cur, 'pricing_data', '') or '{}')
== yaml.safe_load(yaml_str))
except Exception:
same = (getattr(cur, 'pricing_data', '') or '') == yaml_str
await sor.sqlExe(
"UPDATE pricing_program SET description=${d}$ WHERE id=${i}$",
{"d": pp_desc[:1000], "i": ppid})
await sor.sqlExe("COMMIT", {})
if same:
results.append({"model": vmid, "ok": True, "ppid": ppid,
"action": "无变化(幂等跳过,未产生新时序行)",
"items": len(mitems), "factor": factor,
"dimensions": sorted(dims_all)})
continue
if cur is not None and str(getattr(cur, 'enabled_date', ''))[:10] == now:
await sor.sqlExe(
"UPDATE pricing_program_timing SET pricing_data=${y}$, name=${n}$ "
"WHERE id=${i}$",
{"y": yaml_str, "n": "%s %s计价" % (vmid, factor),
"i": getattr(cur, 'id', '')})
await sor.sqlExe("COMMIT", {})
action = "原地更新(同日替换,不产生历史行)"
results.append({"model": vmid, "ok": True, "ppid": ppid,
"action": action, "items": len(mitems),
"factor": factor, "dimensions": sorted(dims_all)})
continue
# 历史生效行 → 拉链
await sor.sqlExe(
"UPDATE pricing_program_timing SET expired_date=${d}$ "
"WHERE ppid=${p}$ AND expired_date='9999-12-31'",
{"d": now, "p": ppid})
action = "更新(拉链:旧行截至今日,新时序生效 %s" % now
else:
ppid = getID()
await sor.C("pricing_program", {
"id": ppid, "name": "%s 定价" % vmid, "ownerid": "0",
"providerid": "", "pricing_belong": "",
"description": pp_desc[:1000], "currency": currency})
action = "新建"
await sor.C("pricing_program_timing", {
"id": getID(), "ppid": ppid, "name": "%s %s计价" % (vmid, factor),
"pricing_data": yaml_str,
"enabled_date": now, "expired_date": "9999-12-31"})
await sor.sqlExe("COMMIT", {})
# 挂模型
await sor.sqlExe("UPDATE llm_model SET ppid=${p}$ WHERE id=${i}$",
{"p": ppid, "i": model_id})
await sor.sqlExe("COMMIT", {})
results.append({"model": vmid, "ok": True, "ppid": ppid,
"action": action, "items": len(mitems),
"factor": factor,
"dimensions": sorted(dims_all)})
ok_n = sum(1 for r in results if r.get("ok"))
return json.dumps({
"summary": "定价导入:%d/%d 个模型成功" % (ok_n, len(results)),
"results": results,
"convention": "一个定价方案只服务一个模型(无 model 维度/无 filters维度平铺"
"定价完全相同的模型共享同一 ppid",
}, ensure_ascii=False)
# ────────────────────── 工具 6模型测试真实调用 ──────────────────────
async def _h_test_model_call(sor, params, ctx):
"""真实调用测试:走完整治理链(门禁→上游→结算),返回调用结果+流水ID。
参数model_name 必填prompt 文本提示t2t业务参数放 params JSON
(媒体统一三数组契约 image_files/audio_files/video_files——字符串或数组
均可;另 resolution/duration 等timeout 等待秒数(异步模型默认 600
前置:供应商账号已配 api_key无 key 会报可行动错误,不静默)。
"""
err = await _require_owner(sor, ctx)
if err:
return err
model_name = (params.get("model_name") or "").strip()
if not model_name:
return "缺 model_name"
prompt = (params.get("prompt") or "回复两个字:正常").strip()
try:
extra = json.loads(params.get("params") or "{}")
except Exception:
return "params 不是合法 JSON"
timeout_s = int(_f(params.get("timeout"), 0)) or 600
try:
from pipeline_llm.inference import chat_inference
except Exception as e:
return "推理模块未加载:%s" % str(e)[:100]
payload = {"model": model_name,
"messages": [{"role": "user", "content": prompt}]}
payload.update(extra)
if timeout_s:
payload["_timeout"] = timeout_s
task_ref = "agent-test:%s:%d" % (model_name[:20], int(time.time()))
t0 = time.time()
try:
data = await chat_inference(ctx.get("org_id", "") or "0",
ctx.get("user_id", ""), payload,
model_name=model_name, task_ref=task_ref)
except Exception as e:
return json.dumps({
"ok": False, "model": model_name, "task_ref": task_ref,
"elapsed_sec": round(time.time() - t0, 1),
"error": str(e)[:400],
"hint": "常见原因账号无api_key/余额不足/端点超时/模板配置缺失——按错误消息处置",
}, ensure_ascii=False)
elapsed = round(time.time() - t0, 1)
content = ""
try:
content = data["choices"][0]["message"]["content"]
except Exception:
content = str(data)[:200]
out = {
"ok": True, "model": model_name, "task_ref": task_ref,
"elapsed_sec": elapsed,
"content_head": str(content)[:200],
"usage": data.get("usage") or {},
}
if data.get("media"):
out["media"] = data["media"] # 生成物本地持久 URL已落地
if data.get("task_id"):
out["task_id"] = data["task_id"]
return json.dumps(out, ensure_ascii=False)
# ────────────────────── 工具 7记账检查 ──────────────────────
async def _h_check_model_accounting(sor, params, ctx):
"""记账正确性检查三态created/accounted/failed
按 task_ref测试调用返回的或 model_name 查最近流水:
1. 流水存在性 + accounting_status
2. usages 计价因子完整性
3. accounted → charge 金额与定价引擎重算对比(独立复算,不信记账侧)
4. failed → 给出 note 原因与处置建议
5. created → 出账循环 60 秒一轮,提示等待或查 worker 进程
"""
err = await _require_owner(sor, ctx)
if err:
return err
task_ref = (params.get("task_ref") or "").strip()
model_name = (params.get("model_name") or "").strip()
if not task_ref and not model_name:
return "需要 task_ref测试调用返回值或 model_name 之一"
if task_ref:
recs = await sor.sqlExe(
"SELECT id, model_id, status, accounting_status, charge, cost, usages, "
"ppid, note, created_at FROM llm_usage WHERE task_ref=${t}$ "
"ORDER BY created_at DESC LIMIT 3", {"t": task_ref})
else:
recs = await sor.sqlExe(
"SELECT u.id, u.model_id, u.status, u.accounting_status, u.charge, u.cost, "
"u.usages, u.ppid, u.note, u.created_at FROM llm_usage u "
"JOIN llm_model m ON m.id=u.model_id "
"WHERE m.name=${n}$ OR m.vendor_model_id=${n}$ "
"ORDER BY u.created_at DESC LIMIT 3", {"n": model_name})
await sor.sqlExe("COMMIT", {})
if not recs:
return "未找到流水task_ref=%s model=%s)——调用可能没发生或没结算" % (
task_ref, model_name)
out_rows = []
for r in recs:
row = dict(r)
st = row.get("accounting_status", "")
item = {
"usage_id": row.get("id", ""),
"call_status": row.get("status", ""),
"accounting_status": st,
"charge": float(row.get("charge") or 0),
"usages": row.get("usages", "") or "",
"ppid": row.get("ppid", "") or "",
"created_at": str(row.get("created_at", "")),
}
if st == "failed":
item["verdict"] = "记账失败"
item["reason"] = (row.get("note") or "")[:200]
item["hint"] = ("常见处置模型未挂ppid→apply_model_pricing"
"模型未映射产品→apply_llm_config 已自动映射2026-09-08"
"仍报未映射说明自动映射失败(看 apply 返回 product_mapping.note"
"多为产品模块未加载),重跑一次 apply 或告知用户;"
"定价无匹配档位→核对usages维度值与定价YAML是否一致")
elif st == "created":
# 2026-09-06 用户定夺:只有 status='SUCCEEDED' 的行才写 accounting_status
# 非 SUCCEEDED 行为 NULL不再挂 created 冒充待记账)——
# 走到这里必然是 SUCCEEDED 成功调用的真待记账行
item["verdict"] = "待记账出账循环60秒一轮稍后复查持续不变查记账worker进程"
elif not st:
# accounting_status=NULL无记账意义调用失败行/充值对账行)
call_st = row.get("status", "")
if call_st == "recharge":
item["verdict"] = "充值对账流水非计费用量accounting_status=NULL"
else:
item["verdict"] = "调用未成功行status=%s不计费accounting_status=NULL" % call_st
item["reason"] = (row.get("note") or "")[:200]
elif st == "accounted":
# 独立复算:用 usages 因子过定价引擎,对比 charge
item["verdict"] = "已记账"
try:
usage_data = json.loads(row.get("usages") or "{}")
except Exception:
usage_data = {}
ppid = row.get("ppid") or ""
if ppid and usage_data:
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
fn = getattr(env, "buffered_charging", None)
if fn:
prices = await fn(ppid, usage_data)
expect = round(sum(float(getattr(p, "amount", 0) or 0)
for p in (prices or [])), 6)
item["recomputed_amount"] = expect
item["amount_match"] = abs(expect - float(row.get("charge") or 0)) < 0.01
if not item["amount_match"]:
item["verdict"] = "已记账但金额不符(复算 %.4f ≠ charge %.4f" % (
expect, float(row.get("charge") or 0))
except Exception as e:
item["recompute_error"] = str(e)[:150]
else:
item["verdict"] = "已记账缺ppid或usages无法独立复算"
out_rows.append(item)
return json.dumps({"rows": out_rows}, ensure_ascii=False)
# ────────────────────── 工具 8模块/应用信息查询 ──────────────────────
async def _h_platform_modules(sor, params, ctx):
"""列出平台已装载的业务模块(内部 agent 了解平台构成用)。"""
err = await _require_owner(sor, ctx)
if err:
return err
mods = []
for name, title in [
("pipeline_core", "产线核心(会话/技能/项目)"),
("pipeline_service", "执行引擎(任务链/角色/治理)"),
("pipeline-llm", "模型治理(供应商/账号/模型/定价/记账)"),
("pipeline-bidding", "投标产线"),
("pipeline-opportunity", "商机产线"),
("pipeline-sdlc", "开发产线前端"),
("pipeline-ops", "运维"),
("pipeline-task", "任务中心"),
]:
try:
__import__(name.replace("-", "_"))
loaded = True
except Exception:
loaded = False
mods.append({"module": name, "title": title, "loaded": loaded})
return json.dumps(mods, ensure_ascii=False)
# ────────────────────── 工具 9只读数据库查询 ──────────────────────
# 根因2026-09-07 记账失败排查实录agent 想核实库内数据(产品映射/记账流水)时,
# 工具集里没有 DB 查询动作,只能拿 run_command 手搓连库脚本——撞上沙箱四重摩擦:
# ① bwrap 的 /tmp 是会话私有 tmpfs脚本不跨命令持久写完下一条就 No such file
# ② /d/pipeline 只读 bind应用目录写不进
# ③ run_command 输出双重截断stdout[:2000] + tool_result[:500]),无截断提示
# ④ 框架 API 逐个踩坑initEnv 签名/sqlor % 格式化/SHOW 语句返回 int
# 十余轮试错查一条数据。补只读查询工具根治SELECT 白名单 + ${k}$ 强制参数化 +
# 行数/字符上限显式截断 + SELECT 后 COMMIT防 MDL 锁,已知坑)。
_DB_QUERY_MAX_ROWS = 200
_DB_QUERY_MAX_CHARS = 8000
_DB_QUERY_MAX_TABLES = 10
def _validate_readonly_sql(sql: str) -> str:
"""只读校验:仅单条 SELECT。返回 ''=通过,否则可行动的错误信息。"""
s = re.sub(r"--[^\n]*", " ", sql or "") # 去行注释(防注释夹带)
s = re.sub(r"/\*.*?\*/", " ", s, flags=re.S) # 去块注释
s = " ".join(s.split()).strip()
if not s:
return "SQL 为空"
if ";" in s.rstrip(";"):
return "只允许单条语句(禁止分号分隔多条)"
head = s.lstrip("(").lstrip().split(" ", 1)[0].lower()
if head != "select":
return ("只允许 SELECT 查询(收到 %s。SHOW/DESC 不支持——查表结构用 tables 参数"
"(返回列定义),或 SELECT information_schema.COLUMNS" % head)
low = s.lower()
for bad in ("into outfile", "into dumpfile", "load_file(", "for update",
"lock in share mode"):
if bad in low:
return "禁止 %s 子句(只读工具)" % bad
# 裸 % 会被 aiomysql 当格式占位符 → "not enough arguments for format string"
# 内部助手实录踩坑。LIKE 通配符必须走 ${k}$ 参数化由 params 传值。
if "%" in s:
return ("SQL 里禁止直接写 %(会被数据库驱动误当格式占位符报 "
"not enough arguments。用 ${key}$ 占位符 + params 传值:"
"如 LIKE ${k}$ 配 params {\"k\": \"%xxx%\"}")
return ""
async def _h_db_query(sor, params, ctx):
"""只读数据库查询SELECT 白名单 / tables 表结构捷径)。
- sql + params单条 SELECT占位符 ${k}$ 参数化,禁止裸 %
- tables逗号分隔表名直接返回各表列定义免手写 information_schema SQL
- 行数上限 200、输出上限 8000 字符,超限显式告知(禁止静默截断)
- SELECT 后 COMMIT 释放 MDL 锁(已知坑)
"""
err = await _require_owner(sor, ctx)
if err:
return err
sql = (params.get("sql") or "").strip()
tables = (params.get("tables") or "").strip()
ns = params.get("params") or {}
if isinstance(ns, str):
try:
ns = json.loads(ns)
except (json.JSONDecodeError, ValueError):
return "params 必须是 JSON 对象,如 {\"k\": \"%xxx%\"}"
if not isinstance(ns, dict):
return "params 必须是 JSON 对象(键对应 SQL 里的 ${键}$ 占位符)"
# 防御sqlor.sqlExe 见 ns 含 'page' 键会走分页路径返回 DictObject(total,rows)
# 而非行列表——占位符命名撞 'page' 时静默变形,这里剔除。
ns.pop("page", None)
# ── 分支 1tables 表结构捷径 ──
if tables and not sql:
tlist = [t.strip() for t in tables.split(",") if t.strip()][:_DB_QUERY_MAX_TABLES]
out = {}
try:
for t in tlist:
# 注意sqlor 把返回列名统一转小写sor.py _get_data
# 故用 AS 别名取小写键,不能 getattr(r, "COLUMN_NAME")。
recs = await sor.sqlExe(
"SELECT COLUMN_NAME AS col_name, COLUMN_TYPE AS col_type, "
"IS_NULLABLE AS col_null, COLUMN_KEY AS col_key, "
"COLUMN_DEFAULT AS col_default FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${t}$ "
"ORDER BY ORDINAL_POSITION", {"t": t})
await sor.sqlExe("COMMIT", {})
out[t] = [{"name": getattr(r, "col_name", ""),
"type": getattr(r, "col_type", ""),
"null": getattr(r, "col_null", ""),
"key": getattr(r, "col_key", ""),
"default": getattr(r, "col_default", None)}
for r in (recs or [])]
except Exception as e:
return "查表结构失败: %s" % str(e)[:300]
empty = [t for t in tlist if not out.get(t)]
note = ("表不存在或无列: %s" % ",".join(empty)) if empty else ""
text = json.dumps({"tables": out}, ensure_ascii=False, default=str)
if note:
text += "\n[⚠️ %s]" % note
return text
if not sql:
return "需要 sqlSELECT 语句)或 tables逗号分隔表名查列定义之一"
# ── 分支 2SELECT 查询 ──
verr = _validate_readonly_sql(sql)
if verr:
return verr
try:
recs = await sor.sqlExe(sql, ns)
await sor.sqlExe("COMMIT", {})
except Exception as e:
msg = str(e)[:300]
hint = ""
if "Unknown column" in msg or "1054" in msg:
hint = "(列名不存在:先用 tables 参数查该表的列定义再改 SQL"
elif "Unknown table" in msg or "1146" in msg:
hint = "(表不存在:核对表名,如 llm_usage/product/llm_model"
elif "not enough arguments" in msg:
hint = "SQL 含裸 % 或占位符缺值LIKE 等用 ${k}$ + params 传值)"
return "SQL 执行失败: %s%s" % (msg, hint)
# sqlor 返回 DictObjectdict 子类__setattr__ 写入键而非 __dict__——
# 必须 dict(r) 转换,不能用 r.__dict__永远为空。同 check_model_accounting 先例。
rows = [dict(r) for r in (recs or [])]
total = len(rows)
note = ""
if total > _DB_QUERY_MAX_ROWS:
rows = rows[:_DB_QUERY_MAX_ROWS]
note = ("⚠️ 结果共 %d 行,只返回前 %d 行——请加 WHERE/LIMIT 收窄"
% (total, _DB_QUERY_MAX_ROWS))
text = json.dumps({"total": total, "rows": rows}, ensure_ascii=False, default=str)
while rows and len(text) > _DB_QUERY_MAX_CHARS:
rows.pop()
text = json.dumps({"total": total, "rows": rows}, ensure_ascii=False, default=str)
note = ("⚠️ 输出超 %d 字符上限,已截断到前 %d 行(共 %d 行)——请加 WHERE/LIMIT 收窄"
% (_DB_QUERY_MAX_CHARS, len(rows), total))
if note:
text += "\n[%s]" % note
return text
# ────────────────────── 工具定义 ──────────────────────
PLATFORM_TOOLS = [
ToolDefinition(
name="platform_llm_status",
description="查看模型治理状态:供应商/账号余额/模型定价/各状态分布。用户问「模型配置状态/账号余额」时调用。仅owner组织角色可用。",
parameters={},
category="platform",
),
ToolDefinition(
name="fetch_model_doc",
description="抓取大模型供应商的 API 文档页面返回纯文本。配置新模型前先用此抓取官方文档。仅owner组织角色可用。",
parameters={"url": "文档页面 URL必须是公网 http/https"},
category="platform",
),
ToolDefinition(
name="extract_llm_api_spec",
description="通读文档文本LLM 提取 API 配置规格(端点/协议/请求响应格式/定价)。配合 fetch_model_doc 使用。仅owner组织角色可用。",
parameters={"doc_text": "fetch_model_doc 返回的文档文本"},
category="platform",
),
ToolDefinition(
name="apply_llm_config",
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等。推荐 use_last_extract=true 取本会话锚定规格(禁止把规格复制进 spec——复制即编造需改动加 overridesvendor_name/base_url/protocol/doc_url/doc_notes/model_capability——用户纠正能力分类时传 model_capability 而非重提取。仅owner组织角色可用。",
parameters={
"use_last_extract": "true=用本会话 extract_llm_api_spec 锚定的规格(推荐,会话级隔离)",
"overrides": "覆盖项 JSON白名单vendor_name/base_url/protocol/doc_url/doc_notes/model_capability。model_capability={模型ID:能力} 如 {\\\"wan3.0-video-prime\\\": \\\"r2v\\\"}——用户纠正能力分类时传它+use_last_extract 重新 apply不要重提取模板会按新能力重建",
"spec": "仅无锚定时的兜底extract 返回的 JSON 规格原文",
},
category="platform",
# 交互原则2026-09-05 用户定):配置链工具不设确认门——缺信息才问,
# 否则一口气做完给测试结果,用户发现问题再按说明修改。
),
ToolDefinition(
name="apply_model_pricing",
description="定价自动导入建定价方案pricing_program+时序YAML并挂模型 ppid。幂等内容无变化跳过同日原地更新历史行才拉链——时序表始终只一条有效行。一个定价方案只服务一个模型定价相同的模型共享同一 ppid。推荐 use_last_extract=true 取本会话锚定规格(含 pricing/doc_url禁止复制转述。仅owner组织角色可用。",
parameters={
"use_last_extract": "true=用本会话锚定规格的 pricing+doc_url推荐",
"spec": "仅无锚定时的兜底extract 返回的 JSON 规格原文",
},
category="platform",
),
ToolDefinition(
name="test_model_call",
description="模型真实调用测试:走完整治理链(门禁→上游→结算)。返回 ok/content/usage/media生成物本地URL/task_ref。前置供应商账号已配 api_key。异步模型视频等等待至任务完成默认600秒。仅owner组织角色可用。",
parameters={
"model_name": "模型名llm_model.name 或 vendor_model_id",
"prompt": "文本提示词t2t 默认「回复两个字:正常」;生成类填生成描述)",
"params": "业务参数 JSON 字符串(生成类模型用;媒体统一三数组契约 "
"image_files/audio_files/video_files字符串或数组均可"
"{\\\"image_files\\\": [\\\"https://...\\\", \\\"https://...\\\"], "
"\\\"resolution\\\": \\\"480P\\\", \\\"duration\\\": 5}",
"timeout": "等待秒数默认600上限900",
},
category="platform",
),
ToolDefinition(
name="check_model_accounting",
description="记账正确性检查(三态 created/accounted/failed查最近流水的记账状态/usages因子/charge金额accounted 时用定价引擎独立复算金额对比。failed 给原因与处置建议。仅owner组织角色可用。",
parameters={
"task_ref": "test_model_call 返回的 task_ref优先",
"model_name": "或按模型名查最近流水",
},
category="platform",
),
ToolDefinition(
name="platform_modules",
description="列出平台已装载的业务模块清单。用户问「平台有哪些模块」时调用。仅owner组织角色可用。",
parameters={},
category="platform",
),
ToolDefinition(
name="db_query",
description="只读数据库查询(排查/核实库内数据的唯一正道——禁止用 run_command 手搓连库脚本)。"
"两种用法①sql+params 执行单条 SELECT占位符 ${k}$ 参数化SQL 内禁写裸 %"
"LIKE 通配符放 params 传值如 {\"k\":\"%xxx%\"}②tables=逗号分隔表名,直接返回列定义"
"(免查 information_schemaSQL 报列名/表名错误时先用它核对结构)。"
"行数上限200、输出上限8000字符超限会显式提示收窄。仅owner组织角色可用。",
parameters={
"sql": "单条 SELECT 语句(占位符 ${k}$;禁裸 %、禁多语句、禁 INTO OUTFILE",
"params": "占位符取值 JSON 对象,如 {\"k\": \"%happyhorse%\", \"id\": \"abc123\"}",
"tables": "(与 sql 二选一)逗号分隔表名,返回各表列定义,如 llm_usage,product",
},
category="platform",
),
]
PLATFORM_PROMPT = """
你是产线平台的内部运维 agent服务对象是 owner 组织的角色。
## 模型自动配置全链工作流(用户给文档 URL 或正文要求配置模型时,一口气独立完成)
0. **输入形态判定**:用户给的是 URL → 走 fetch_model_doc用户直接粘贴了文档正文
(无 URL 或 URL 抓不到)→ **跳过 fetch_model_doc把正文原样作为 doc_text 传给
extract_llm_api_spec**(禁止凭记忆改写正文)。用户单独给了定价链接/正文时:
定价链接可再 fetch_model_doc 抓取,定价正文直接并入 doc_text 一起提取。
1. **先 `load_skill` 加载 `model-auto-config`**(含媒体转换铁律/能力分类先行/定价新模式),严格执行
2. `fetch_model_doc` 抓取官方文档(返回文本末尾带 [出处URL] 行)
3. `extract_llm_api_spec` 通读文档提取规格——**成功后规格自动锚定到本会话缓存**
(会话级隔离),返回里有 __anchored__ 提示
4. **不要停下来找用户确认**——直接进入下一步落库。只有文档里确实缺失、
且无法从上下文合理推断的信息(如 api_key、能力分类需新增才向用户提问。
5. `apply_llm_config` 传 **{"use_last_extract": true}**(用户要求改名等只加
overrides 白名单覆盖项,如 {"overrides": {"vendor_name": "阿里云百炼"}})。
**绝对禁止把规格 JSON 复制/转述进 spec 参数——转述必编造结构(历史教训:
三轮全编造 vendor{}/api_profile{} 等不存在的字段)。**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定)
7. `test_model_call` 真实调用测试(前置:账号已配 api_key没配则如实告知用户去补
不要伪造结果)——生成类模型传 params媒体三数组 image_files/audio_files/
video_files + resolution/duration 等)
8. `check_model_accounting` 检查记账(传上一步的 task_ref
- accounted 且 amount_match=true → 全链通过,汇报金额
- created → 等 60~120 秒再查一次(出账循环 60 秒一轮)
- failed → 按 reason/hint 处置;需要核实库内数据(产品映射/定价挂接/流水明细)
一律用 `db_query` 只读查询,**禁止用 run_command 手搓连库脚本**(沙箱 /tmp 不跨
命令持久、平台目录只读、输出双截断——手搓必陷多轮试错)。
产品映射由 apply_llm_config 自动完成2026-09-08 起)——若仍报「未映射到产品」,
回看 apply 返回的 product_mapping.note多为产品模块未加载重跑一次
apply_llm_config幂等触发自动映射仍失败才如实告知用户。
9. 汇报:配置/定价/测试/记账四段结果 + 出处 URL + 遗留事项(如模板需人工核对项)。
用户发现问题后按其说明修改,再重新走对应环节(全链幂等,重跑安全)。
## 上线检查工作流(用户选定模型要求「上线检查」时,按模型配置后的测试过程处理)
1. `test_model_call` 对指定模型真实调用(完整治理链:门禁→上游→结算)。
生成类模型t2i/t2v/i2v/r2v 等)缺必要业务参数(媒体输入/分辨率等)时**先问用户**
禁止编造参数或跳过测试t2t 用默认提示词即可。
2. `check_model_accounting` 传上一步 task_ref 检查记账:
accounted 且 amount_match → 记账通过created → 等 60~120 秒复查一次再下结论;
failed → 按 reason/hint 报告原因与处置建议,需核实库内数据用 `db_query`
(如 `SELECT ... FROM product WHERE resource_ref_id=${id}$` 查产品映射是否存在),
禁止 run_command 手搓连库脚本。
3. `platform_llm_status` 复核该模型/定价/供应商当前状态active/ppid 挂接等)。
4. **最后明确告诉用户检查结果:通过 / 不通过** + 各环节明细(调用/记账/状态)
+ 遗留事项。结论必须如实——任何一环失败都是不通过,禁止粉饰。
## 交互原则用户定2026-09-05
- **只有缺失信息需要用户补充时才交互**api_key、业务参数、需新增能力分类等
- 否则从抓取到记账检查**一口气做完**,最后给出完整测试结果
- **不要每步找用户确认**——落库/定价/测试都直接执行(全部幂等,改起来安全)
- 用户发现问题 → 按用户说明修改重跑,这才是正确的纠错循环
## 工具报错自救规则
- 报「spec 结构不符/缺少 xxx」→ **不要改写结构重试**,改用 use_last_extract=true
本会话没锚定就重新走 fetch→extract锚定是会话级的跨会话不可见
- 报「能力分类未登记」→ 如实转告用户需先加能力分类(这是人工评审门,不要绕过)
- 报「media_audit/runtime_note/需人工核对」警告 → 原样转告用户,不假装完成
## 硬规则
- **定价只允许来自文档原文**doc_quote 强制),禁止编造/换算/推导
- **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL
- 能力分类不存在 → 先加分类(字典/种子/提示词/端点注释四处同步)再配模型,禁止塞近似分类
- 能力分类铁律:按文档正文的输入/输出模态声明判定,禁止照调用示例判(示例常只演示
最简形态——wan3.0 实测「四模态全能参考」被示例判成 t2v。extract 返回
__capability_alert__ 说明描述与能力矛盾apply 会自动纠正;用户口头纠正能力时,
传 overrides={"model_capability": {"模型ID": "能力"}} + use_last_extract 重新 apply
禁止重提取(同样的提示词只会同样误判)
- 生成类模型媒体铁律:上行统一三数组契约 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 就停在配置阶段如实汇报,禁止跳过测试谎称完成
- 所有工具仅 owner 组织角色可用,权限报错时如实转告用户
## 平台知识
平台模块清单用 `platform_modules` 查询技能库中「平台」相关技能skills_library/all/ 与
pipelines/platform_general/)包含各模块的开发规范与踩坑记录,需要时用 load_skill 加载。
"""
PLATFORM_HANDLERS = {
"platform_llm_status": _h_platform_llm_status,
"fetch_model_doc": _h_fetch_model_doc,
"extract_llm_api_spec": _h_extract_llm_api_spec,
"apply_llm_config": _h_apply_llm_config,
"apply_model_pricing": _h_apply_model_pricing,
"test_model_call": _h_test_model_call,
"check_model_accounting": _h_check_model_accounting,
"platform_modules": _h_platform_modules,
"db_query": _h_db_query,
}
def register_platform_ability():
"""注册平台内部 agent 能力包(幂等)。"""
ability = PipelineAbility(
pipeline_id=PLATFORM_PIPELINE_ID,
name="平台内部 agent",
tools=PLATFORM_TOOLS,
system_prompt=PLATFORM_PROMPT,
handlers=PLATFORM_HANDLERS,
roles=[],
menus=[
{"label": "📊 模型治理", "icon": "", "url": "/pipeline-llm",
"type": "tab"},
],
)
register_ability(ability)
return ability
register_platform_ability()