pipeline-platform/pipeline_platform/platform_ability.py

625 lines
28 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

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

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

"""
pipeline-service: platform_ability — 平台内部 agent 能力包pipeline_id=platform_general
产线平台自身的运维/管理 agent拥有平台应用和各模块的技能
核心能力是「通读模型 API 文档 → 自动生成模型治理配置(供应商/适配模板/模型/定价)」。
权限模型(代码层硬门禁,不依赖 prompt
所有工具 handler 入口校验调用者持有 owner 组织的角色orgtypeid='owner',含通配 'owner.*')。
内部 agent 只服务 owner 组织的角色——其他组织的用户即使打开页面也调不动任何工具。
配置生成链路(用户给定 API 文档 → 完成配置):
① fetch_model_doc抓取文档页面SSRF 防护:仅 http(s) 公网域名)
② extract_llm_api_specLLM 从文档提取端点/协议/请求响应格式/定价
③ apply_llm_config写库——复用 pipeline-llm 模块的 CRUD 函数
llm_vendor.endpoints + llm_api_profile 模板 + llm_model 含四价)
"""
import asyncio
import json
import logging
import re
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"
# 当前运行时调用链支持的协议llm_bridge 固定 OpenAI 兼容路径)。
# 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。
RUNTIME_PROTOCOLS = ("openai_compat",)
# ────────────────────── 权限门禁(代码层) ──────────────────────
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)
return text
# ────────────────────── 工具 3提取配置规格LLM ──────────────────────
_EXTRACT_PROMPT = """你是大模型 API 配置专家。通读下面这份模型 API 文档,提取配置规格。
只输出一个 JSON 对象(不要 markdown 代码块),字段:
{
"vendor_name": "供应商名称",
"base_url": "API 基础地址(如 https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai_compat | dashscope_async | custom能走 /chat/completions 的填 openai_compat",
"endpoints": [{"base_url": "...", "region": "domestic|international", "timeout": 60}],
"chat_path": "对话接口路径(如 /chat/completions",
"auth_header": "认证头格式说明(如 Bearer API_KEY",
"request_fields": ["请求体字段名列表"],
"response_format": "响应格式说明content 字段路径 + usage 字段路径)",
"async_steps": [{"purpose": "query|download", "path": "该步骤接口路径", "method": "GET|POST", "request_fields": ["请求字段名列表"], "response_format": "该步骤响应格式说明"}],
"models": [{"vendor_model_id": "供应商侧模型ID", "capability": "t2t|t2i|i2t|t2v|i2v|embedding|rerank|tts|asr",
"sync_mode": "sync|async提交后需轮询查询结果的填 async",
"description": "一句话说明"}],
"doc_notes": "文档中影响配置的关键注意点"
}
异步模型规则:
- 文档描述「先提交任务、再轮询查询结果」的模型sync_mode 填 async并提取 async_steps
至少一条 purpose=query查询任务状态/结果);若文档另有独立下载/取文件接口,再加一条 purpose=download。
- 同步模型 async_steps 填空数组 []。
定价规则:
- 价格按文档原文记录,并保留文档标注的单位(在 description 中写明,如「元/百万tokens」
- 禁止自行换算、推导或统一单位——单位是否吻合由上线测试阶段的记账核对验证
- 文档没写价格的模型:价格字段填 null禁止编造价格在 description 注明
- cost_* 未知时与 price_* 相同(无供应商折扣时成本=售价)
文档内容:
"""
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]
return json.dumps(spec, ensure_ascii=False)
# ────────────────────── 工具 4写入配置幂等 ──────────────────────
def _f(v, default=0.0):
try:
return float(v)
except (TypeError, ValueError):
return default
async def _h_apply_llm_config(sor, params, ctx):
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型(含定价)。
幂等:供应商按名称复用;模型按 (vendor, vendor_model_id) 复用——
已存在则更新定价,不重复创建。
"""
err = await _require_owner(sor, ctx)
if err:
return err
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
vendor_name = (spec.get("vendor_name") or "").strip()
if not vendor_name:
return "spec 缺少 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 为空——文档里没有可配置的模型?"
from appPublic.uniqueID import getID
# 1. 供应商:按名称复用
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 = []
for ep in endpoints:
bu = (ep.get("base_url") or "").rstrip("/")
if bu and bu not in [(e.get("base_url") or "").rstrip("/") for e in merged]:
merged.append({"base_url": bu, "region": ep.get("region") or "domestic",
"timeout": int(_f(ep.get("timeout"), 60))})
added_eps.append(bu)
await sor.sqlExe(
"UPDATE llm_vendor SET endpoints=${e}$, protocol=${p}$, updated_at=NOW() "
"WHERE id=${i}$",
{"e": json.dumps(merged, ensure_ascii=False), "p": protocol, "i": vendor_id})
await sor.sqlExe("COMMIT", {})
vendor_action = "复用供应商 %s(新增端点 %d 个)" % (vendor_id, len(added_eps))
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))} for ep in endpoints]
await sor.C("llm_vendor", {
"id": vendor_id, "name": vendor_name, "protocol": protocol,
"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)
# 2. 适配模板:按 (协议×能力) 复用,缺则新建
profile_ids = {}
caps = sorted(set((m.get("capability") or "t2t") for m in models))
for cap in caps:
recs = await sor.sqlExe(
"SELECT id FROM llm_api_profile WHERE protocol=${p}$ AND capability=${c}$ "
"AND status='active' ORDER BY created_at DESC LIMIT 1",
{"p": protocol, "c": cap})
await sor.sqlExe("COMMIT", {})
if recs:
profile_ids[cap] = getattr(recs[0], "id", "")
continue
req_tpl = _default_templates(protocol, cap, spec)
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": "%s-%s-自动配置" % (protocol, cap),
"protocol": protocol, "capability": cap,
"path": req_tpl["path"],
"headers": json.dumps(req_tpl["headers"], ensure_ascii=False),
"request_template": req_tpl["req"], "response_template": req_tpl["resp"],
"param_schema": json.dumps([{"name": "prompt", "label": "提示词",
"uitype": "textarea", "required": True}],
ensure_ascii=False),
"status": "active"})
profile_ids[cap] = pid
# 3. 模型:按 (vendor, vendor_model_id) 幂等
created, updated, skipped = [], [], []
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()
# 异步模型:后续步骤模板链(提交后顺序执行 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 ""
recs = await sor.sqlExe(
"SELECT id FROM llm_model WHERE vendor_id=${v}$ AND vendor_model_id=${m}$",
{"v": vendor_id, "m": vmid})
await sor.sqlExe("COMMIT", {})
if recs:
mid = getattr(recs[0], "id", "")
await sor.sqlExe(
"UPDATE llm_model SET description=${d}$, "
"sync_mode=${sm}$, query_profile_ids=${q}$, updated_at=NOW() "
"WHERE id=${i}$",
{"d": desc, "sm": sync_mode, "q": query_ids_json, "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)))
return json.dumps({
"vendor": vendor_action, "profiles": profile_ids,
"models_created": created, "models_updated": updated, "models_skipped": skipped,
"runtime_note": rt_note,
}, ensure_ascii=False)
def _default_templates(protocol: str, capability: str, spec: dict) -> dict:
"""生成适配模板默认值path/headers 独立列 + data/response 模板)。
OpenAI 兼容协议用平台标准模板;其他协议按文档格式生成骨架供人工微调。
返回 {"path", "headers", "req", "resp"}。
"""
chat_path = (spec.get("chat_path") or "/chat/completions").strip()
headers = {"Authorization": "Bearer {{api_key}}",
"Content-Type": "application/json"}
if protocol == "openai_compat":
req = json.dumps({
"data": {"model": "{{model}}", "messages": "{{messages}}",
"temperature": "{{temperature}}", "stream": False},
}, ensure_ascii=False)
resp = json.dumps({
"content": "choices[0].message.content",
"usage": {"prompt_tokens": "usage.prompt_tokens",
"completion_tokens": "usage.completion_tokens"},
}, ensure_ascii=False)
return {"path": chat_path, "headers": headers, "req": req, "resp": resp}
# 非标准协议:骨架模板(字段路径来自文档提取),标注需人工核对
req = json.dumps({
"data": {"__from_doc__": spec.get("request_fields") or []},
"__note__": "非 openai_compat 协议骨架模板,需按文档人工核对",
}, ensure_ascii=False)
resp = json.dumps({
"content": spec.get("response_format", "") or "待按文档填写",
"__note__": "需人工核对",
}, ensure_ascii=False)
return {"path": chat_path, "headers": headers, "req": req, "resp": resp}
async def _ensure_step_profile(sor, protocol, cap, vmid, step, spec):
"""异步模型的后续步骤模板query/download按名幂等创建/复用 profile。
step: {"purpose": "query|download", "path", "method", "request_fields", "response_format"}
返回 profile id。
"""
purpose = (step.get("purpose") or "query").strip() or "query"
pname = "%s-%s" % (vmid, purpose)
recs = await sor.sqlExe(
"SELECT id FROM llm_api_profile WHERE name=${n}$", {"n": pname})
await sor.sqlExe("COMMIT", {})
if recs:
return getattr(recs[0], "id", "")
headers = {"Authorization": "Bearer {{api_key}}",
"Content-Type": "application/json"}
method = (step.get("method") or "POST").strip().upper() or "POST"
req = json.dumps({
"method": method,
"data": {"__from_doc__": step.get("request_fields") or [],
"__task_id__": "{{task_id}}"},
"__note__": "异步%s步骤骨架,需按文档人工核对" % purpose,
}, ensure_ascii=False)
resp = json.dumps({
"content": step.get("response_format", "") or "待按文档填写",
"__note__": "需人工核对",
}, ensure_ascii=False)
pid = getID()
await sor.C("llm_api_profile", {
"id": pid, "name": pname, "protocol": protocol, "capability": cap,
"path": (step.get("path") or "").strip(),
"headers": json.dumps(headers, ensure_ascii=False),
"request_template": req, "response_template": resp,
"param_schema": "", "status": "active"})
return pid
# ────────────────────── 工具 5模块/应用信息查询 ──────────────────────
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)
# ────────────────────── 工具定义 ──────────────────────
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="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型含定价。幂等——已有模型只更新定价。仅owner组织角色可用。",
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格"},
category="platform",
requires_confirmation=True,
),
ToolDefinition(
name="platform_modules",
description="列出平台已装载的业务模块清单。用户问「平台有哪些模块」时调用。仅owner组织角色可用。",
parameters={},
category="platform",
),
]
PLATFORM_PROMPT = """
你是产线平台的内部运维 agent服务对象是 owner 组织的角色。
## 模型自动配置工作流(用户给你文档 URL 要求配置模型时)
1. **先 `load_skill` 加载 `model-onboarding`**(配置→测试→上线三步标准流程)和
`auto-api-pricing-config`(模板库),按技能严格执行
2. `fetch_model_doc` 抓取官方文档页面
3. `extract_llm_api_spec` 通读文档提取配置规格——**定价只允许来自文档原文**,文档没写的价格一律 null禁止编造
4. 把提取结果摘要给用户确认(尤其定价和端点),用户确认后
5. `apply_llm_config` 写入(幂等:已有模型只更新定价)
6. 进入测试/上线阶段时,严格按 model-onboarding 技能的判据执行(无 apikey 先补录,测试必查调用成功+记账四项)
## 硬规则
- **定价按文档原文记录**,单位照文档所写如实标注(写入 description禁止自行换算、推导或统一单位单位是否吻合由测试阶段的记账核对验证
- cost_*(供应商成本)未知时与 price_* 相同
- 非 openai_compat 协议的模型:模板会存档但运行时暂不可调用,必须如实告知
- 文档抓取失败/内容不足时如实说明,不要凭记忆编造 API 格式
- 所有工具仅 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,
"platform_modules": _h_platform_modules,
}
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()