pipeline-platform/pipeline_platform/platform_ability.py
yumoqing e5902a4fd4 feat(platform): apply_llm_config自动产品映射(2026-09-08用户需求:添加模型时如有产品模块自动映射)
根治记账failed"模型未映射到产品"不靠人工事后补:
- _sync_model_products:apply落库后调env.import_categories_and_products
  (resource_module=pipeline_llm)增量导入所有active模型为产品(幂等:已映射按
  resource_ref_id+org_id跳过,顺带自愈其他漏映射模型)
- 父类别复用已有pipeline_llm根类别"大模型消费"(防类别树分裂)
- 产品模块未装→跳过不阻断(模型注册已成功,映射是下游增值步骤);失败如实记
  product_mapping.note
- check_model_accounting hint与工作流prompt同步:未映射→重跑apply触发自动映射
2026-09-08 13:58:50 +08:00

2864 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_spec:LLM 从文档提取端点/协议/请求响应格式/定价
③ 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_capability(2026-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-08):apply 阶段的能力守卫必须跳过
# 这些模型——人工指令是权威,守卫再纠正=把用户的纠正改回去
# (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_compat;DashScope 原生接口按交互形态二选一,见下方协议判定规则)",
"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,按次=flat;token计价:文档只给一个输入价=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 参与,
词匹配只能当廉价触发器,不能当裁决者)。
背景:缺陷2(qwen-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 必含「图像/图片」,
# 那是输出声明不是输入声明。此前只排 video(video 在 t2v/i2v 描述必现),
# 没排 image(image 在 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.spec(LLM 手传,历史路径)——结构不符时报错列出期望键。
"""
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_notes(extract_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(%s)schema 提示:%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(%s)param_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 ""
# 幂等键=name(uk_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)
# 生成类能力:出参是文件(图/视频/音频/3D),response 必须 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/status(StrictUndefined 会崩),
# 模板只引用 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_id),StrictUndefined
# 干跑必崩 → 落库被拦,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_expr(e2e 验证过的 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 只打一行 debug(input.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)
# 媒体数组参数缺 multiple:UI 只能选 1 个(soft,权威样例也这样)
if nm in _MEDIA_UITYPE and not f.get("multiple"):
out['soft'].append("媒体数组字段 %s 缺 multiple:true(UI 只能选 1 个)" % nm)
return out
def _biz_field_uitype(name, example, enums):
"""业务参数 → bricks uitype(2026-09-06 用户指正:只准用注册过的类型)。
合法全集(bricks/input.js Input.register):str/hide/tel/date/int/float/check/
checkbox/email/file/image/code/text/password/audio/video/audiorecorder/
audiotext/search/group。**textarea / number 均未注册**,用了前端渲染不出来。
规则:有枚举 → code(UiCode=select,data:[{value,text}]);
数值 → int/float;布尔 → check;其余 → text(UiText 自动增高多行)。
返回 (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)
- 默认值字段名必须是 defaultvalue(input.js 只读它,default 无人消费)
- 数组媒体参数带 multiple:true(input.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+path,base_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):
"""生成定价 YAML(2026-09-05 用户定夺的新模式;2026-09-06 支持多因子):
- 不放 model 过滤:一个定价方案只服务一个模型;定价相同的多个模型共享
同一 ppid(不是往定价里加 model 维度)
- 不用 filters 子结构:维度直接平铺在定价项(引擎对非保留键做 AND 匹配)
- fields 需定义每个维度(role: filter),否则引擎报「在fields中没有定义」
- factors 为列表:token 模型天然多因子(输入/输出/缓存同在一个方案,
生产实证 qwen3.8-max),每条 pricing 用各自的 factor;derived 因子
(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-dist", "分销"),
("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)
# ── 分支 1:tables 表结构捷径 ──
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 "需要 sql(SELECT 语句)或 tables(逗号分隔表名,查列定义)之一"
# ── 分支 2:SELECT 查询 ──
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 返回 DictObject(dict 子类,__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——复制即编造);需改动加 overrides(vendor_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_schema,SQL 报列名/表名错误时先用它核对结构)。"
"行数上限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()