ymq 6953a27937 feat(llm-govern): llm_bridge/llm_proxy 挂治理钩子——门禁前置解析+成功/失败结算
- _resolve_cfg_with_govern: 机构有策略→门禁链(限流/限额/主备容错/端点选择/预授权);
  无策略→__LEGACY__走旧llm表(向后兼容);治理真实失败抛错禁止静默回退
- llm_call/llm_call_msgs/llm_call_msgs_native 三处接入;失败结算释放预授权写failed流水
- proxy_chat_completion(运行环境token路径)同样接入,按上游真实usage结算
2026-09-01 17:27:51 +08:00

371 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""
LLM bridge for pipeline handlers.
Provides a simple async interface for handlers to call LLM APIs.
Looks up model config from the llm database table first,
falls back to environment variables.
"""
import json
import logging
import os
logger = logging.getLogger("pipeline.llm_bridge")
# Cache: model_name -> {api_base, api_key, model_id}
_model_cache: dict = {}
def _decrypt_key(encrypted: str) -> str:
"""Decrypt api_key stored with password_encode. Falls back to plaintext."""
if not encrypted:
return ""
try:
from appPublic.rc4 import unpassword
from appPublic.jsonConfig import getConfig
config = getConfig()
# 与 ahserver.globalEnv.get_password_key() 一致:password_key 为空时用默认 key
key = config.password_key or 'QRIVSRHrthhwyjy176556332'
# unpassword(code, key):code=密文,key=密钥(之前参数顺序写反了)
return unpassword(encrypted, key)
except Exception:
return encrypted # already plaintext or decrypt failed
async def _get_model_config(model_name: str = None, org_id: str = None) -> dict:
"""Look up model config from llm table. Returns dict with api_base, api_key, model_id.
org_id 多租户隔离:非空时只取本机构(org_id=${org}$)的模型,不含系统级兜底;
org_id 为空时不过滤(向后兼容)。
"""
global _model_cache
cache_key = f"{model_name or ''}:{org_id or ''}"
if model_name and cache_key in _model_cache:
return _model_cache[cache_key]
try:
from sqlor.dbpools import DBPools
db = DBPools()
dbname = "pipeline"
async with db.sqlorContext(dbname) as sor:
if model_name:
# name/model_id 双匹配:与 gateway._resolve_and_persist_model 语义一致——
# 调用方传 llm.name 或 API 模型名(model_id)都能解析,避免语义漂移。
sql = ("SELECT api_base, api_key, model_id FROM llm "
"WHERE status='active' AND (name=${name}$ OR model_id=${name}$)")
params = {"name": model_name}
if org_id:
# 与 gateway 一致:本机构模型 + 系统级共享(org_id 为空/0)
sql += " AND (org_id=${org}$ OR org_id='' OR org_id='0')"
params["org"] = org_id
sql += " LIMIT 1"
recs = await sor.sqlExe(sql, params)
else:
sql = "SELECT api_base, api_key, model_id, name FROM llm WHERE status='active'"
params = {}
if org_id:
sql += " AND org_id=${org}$"
params["org"] = org_id
sql += " ORDER BY id LIMIT 1"
recs = await sor.sqlExe(sql, params)
if recs:
r = recs[0]
cfg = {
"api_base": getattr(r, "api_base", "") or "",
"api_key": _decrypt_key(getattr(r, "api_key", "") or ""),
"model_id": getattr(r, "model_id", "") or "",
}
_model_cache[cache_key] = cfg
return cfg
except Exception as e:
logger.warning("llm_bridge: DB lookup failed: %s", e)
return {}
def _no_llm_error(model_name=None, org_id=None) -> ValueError:
"""模型不可用的错误必须真实可行动:写明模型名与原因,前端原样展示、运维据此处理。
禁止笼统的 'No LLM API configured'——用户看到它只会卡住干等。
"""
if model_name:
return ValueError(
f"模型「{model_name}」不可用:llm 表中未找到可用配置"
f"(name/model_id 不匹配、status 非 active"
+ (f",或不属于当前机构 org={org_id}" if org_id else "")
+ ")。请在模型下拉里改选可用模型,或联系管理员在模型管理中补齐配置。"
)
return ValueError(
"没有可用模型:当前会话未指定模型,且 llm 表无启用模型。"
"请在模型下拉中选择模型,或联系管理员配置模型。"
)
# 瞬时错误重试:超时/连接错误/限流/服务端5xx 均重试;配置错误(无key)、鉴权/参数4xx 不重试
_RETRYABLE_STATUS = (429, 500, 502, 503, 504)
_LLM_MAX_ATTEMPTS = 3
# 大上下文(角色 agent 多轮累积)生成偏慢,180s 曾触发超时(空 err=),放宽到 5 分钟;
# connect 单独设 30s,连接建立失败能快速失败并重试,而不是干等 5 分钟。
_LLM_TOTAL_TIMEOUT = 300
_LLM_CONNECT_TIMEOUT = 30
async def _post_chat_completion(url: str, headers: dict, payload: dict) -> dict:
"""POST /chat/completions,带瞬时错误重试。返回解析后的 JSON dict。"""
import aiohttp
import asyncio
last_exc = None
for attempt in range(_LLM_MAX_ATTEMPTS):
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=_LLM_TOTAL_TIMEOUT, connect=_LLM_CONNECT_TIMEOUT),
) as resp:
if resp.status != 200:
text = await resp.text()
err = ValueError("LLM API error %d: %s" % (resp.status, text[:300]))
if resp.status in _RETRYABLE_STATUS and attempt < _LLM_MAX_ATTEMPTS - 1:
last_exc = err
await asyncio.sleep(2 * (attempt + 1))
continue
raise err
data = await resp.json()
# 网关偶发返回 200 但内容无 choices(错误 JSON),视为可重试的瞬时异常;
# 不处理会导致上层 llm_call_msgs_native 的 data["choices"] 抛 KeyError。
if not isinstance(data, dict) or "choices" not in data:
err = ValueError("LLM 响应缺 choices: %s" % json.dumps(data, ensure_ascii=False)[:300])
if attempt < _LLM_MAX_ATTEMPTS - 1:
last_exc = err
await asyncio.sleep(2 * (attempt + 1))
continue
raise err
return data
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
last_exc = e
if attempt < _LLM_MAX_ATTEMPTS - 1:
logger.warning("llm_bridge: 瞬时错误重试 %d/%d: %s", attempt + 1, _LLM_MAX_ATTEMPTS, e)
await asyncio.sleep(2 * (attempt + 1))
continue
raise
raise last_exc if last_exc else ValueError("LLM call failed")
# ────────────────────────── pipeline_llm 治理钩子 ──────────────────────────
# 机构配置了治理(llm_org_policy / llm_model 有记录)→ 调用走门禁链(限流/限额/
# 主备容错/端点选择/预授权),调用后按实际用量结算(双维度记账)。
# 未配置治理 → 返回 __LEGACY__ 走下方旧 llm 表逻辑(向后兼容,现有调用零改动)。
# 治理真实失败(限流/余额不足/候选耗尽)→ 抛 ValueError,消息真实可行动,禁止静默回退
# (否则治理被绕过,限额形同虚设)。
async def _resolve_cfg_with_govern(model, org_id, user_id, est_text):
"""返回 (cfg, gctx)。gctx 非空 = 本次调用受治理(调用后须结算)。"""
gctx = None
try:
from pipeline_llm.gateway import govern_resolve
except ImportError:
govern_resolve = None
if govern_resolve is not None and org_id:
try:
est = max((len(est_text) if est_text else 0) // 2, 200)
ok, res = await govern_resolve(
org_id=org_id, user_id=user_id or '',
model_name=model or '', est_tokens=est)
if ok and isinstance(res, dict):
gctx = res
elif res != '__LEGACY__':
raise ValueError(res)
except ValueError:
raise
except Exception as e:
logger.warning("llm_bridge: 治理前置异常(回退旧表): %s", e)
if gctx:
cfg = {"api_base": gctx["api_base"], "api_key": gctx["api_key"],
"model_id": gctx["model_id"]}
return cfg, gctx
cfg = await _get_model_config(model, org_id=org_id)
return cfg, None
async def _settle_govern(gctx, data, est_text):
"""调用成功后结算:优先上游真实 usage,缺失则按文本长度估算(note=est)。"""
if not gctx:
return
try:
from pipeline_llm.gateway import govern_settle
usage = (data or {}).get('usage') or {}
rt = int(usage.get('prompt_tokens') or 0)
ct = int(usage.get('completion_tokens') or 0)
note = ''
if not usage:
rt = rt or max((len(est_text) if est_text else 0) // 2, 100)
ct = ct or 200
note = 'est'
await govern_settle(gctx, True, rt, ct, note)
except Exception as e:
logger.warning("llm_bridge: 治理结算失败(不阻断调用): %s", e)
async def _settle_govern_failed(gctx, note):
"""调用失败时结算:释放预授权,写 failed 流水。"""
if not gctx:
return
try:
from pipeline_llm.gateway import govern_settle
await govern_settle(gctx, False, 0, 0, str(note)[:200])
except Exception as e:
logger.warning("llm_bridge: 治理失败结算异常: %s", e)
def _msgs_text(messages):
try:
return ' '.join(str(m.get('content', '')) for m in (messages or []) if isinstance(m, dict))
except Exception:
return ''
async def llm_call(prompt: str, model: str = None, temperature: float = 0.7,
org_id: str = None, user_id: str = None) -> str:
"""Call LLM and return text response.
Backend priority:
1. harnessed_agent.llm_chat (if loaded in ServerEnv)
2. DB llm table (api_base + api_key)
3. Environment variables (LLM_API_BASE, LLM_API_KEY, LLM_MODEL)
org_id 多租户隔离:非空时 llm 表查询只取「本机构 + 系统级」模型。
"""
# Priority 1: harnessed_agent
try:
from ahserver.serverenv import ServerEnv
env = ServerEnv()
if hasattr(env, 'llm_chat'):
result = await env.llm_chat(prompt, model=model, temperature=temperature)
if isinstance(result, dict):
return result.get("content", result.get("text", str(result)))
return str(result)
except Exception:
pass
# Priority 2: DB llm table(治理启用时前置走门禁链)
cfg, gctx = await _resolve_cfg_with_govern(model, org_id, user_id, prompt)
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
logger.info("llm_bridge: using %s model config for %s -> %s",
"governed" if gctx else "DB", model, api_base)
else:
# Priority 3: Environment variables
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise _no_llm_error(model, org_id)
import aiohttp
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
}
url = api_base.rstrip("/") + "/chat/completions"
try:
data = await _post_chat_completion(url, headers, payload)
except Exception as e:
await _settle_govern_failed(gctx, e)
raise
await _settle_govern(gctx, data, prompt)
return data["choices"][0]["message"]["content"]
async def call_llm(tenant_id: str, prompt: str, model: str = None, temperature: float = 0.7) -> str:
"""SDLC handler interface — delegates to llm_call."""
return await llm_call(prompt, model=model, temperature=temperature)
async def llm_call_msgs(messages: list, model: str = None, temperature: float = 0.7,
org_id: str = None, user_id: str = None) -> str:
"""Call LLM with full message array (system/user/assistant)."""
import aiohttp
cfg, gctx = await _resolve_cfg_with_govern(model, org_id, user_id, _msgs_text(messages))
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
else:
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise _no_llm_error(model, org_id)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model_id, "messages": messages, "temperature": temperature}
url = api_base.rstrip("/") + "/chat/completions"
try:
data = await _post_chat_completion(url, headers, payload)
except Exception as e:
await _settle_govern_failed(gctx, e)
raise
await _settle_govern(gctx, data, _msgs_text(messages))
return data["choices"][0]["message"]["content"]
async def llm_call_msgs_native(messages: list, tools: list = None, model: str = None,
temperature: float = 0.7, org_id: str = None,
user_id: str = None) -> dict:
"""Native function calling. 传入 tools JSON schema,返回 message dict。
Returns:
{"content": str, "tool_calls": [{"id","type","function":{"name","arguments"}}]}
当模型返回 tool_calls 时,content 通常为空字符串。
"""
import aiohttp
cfg, gctx = await _resolve_cfg_with_govern(model, org_id, user_id, _msgs_text(messages))
if cfg.get("api_key") and cfg.get("api_base"):
api_base = cfg["api_base"]
api_key = cfg["api_key"]
model_id = cfg.get("model_id") or model or "default"
else:
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
api_key = os.environ.get("LLM_API_KEY", "")
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
if not api_key:
raise _no_llm_error(model, org_id)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model_id, "messages": messages, "temperature": temperature}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
url = api_base.rstrip("/") + "/chat/completions"
try:
data = await _post_chat_completion(url, headers, payload)
except Exception as e:
await _settle_govern_failed(gctx, e)
raise
await _settle_govern(gctx, data, _msgs_text(messages))
msg = data["choices"][0]["message"]
return {
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or [],
}