747 lines
35 KiB
Python
747 lines
35 KiB
Python
"""pipeline_llm.gateway — 模型治理引擎:门禁链 + 主备容错 + 账号端点轮转 + 双维度记账。
|
||
|
||
调用链(与设计规范 §2 一致):
|
||
① 个人限流窗口 → ② 组织限流窗口
|
||
→ ③ 个人额度余额 → ④ 组织池余额(预授权冻结)
|
||
→ ⑤ 组织容错策略选模型(主→备)
|
||
→ ⑥ 账号×端点候选池:区域偏好过滤 → 余额加权轮转 + 限流冷却避让
|
||
→ ⑦ 调用(调用方执行)→ ⑧ govern_settle 结算(实际用量,双维度记账)
|
||
|
||
集成方式(复用不改建):
|
||
- llm_bridge._get_model_config 前置调 govern_resolve:机构配了策略/新模型表才走治理,
|
||
否则返回 None 由旧 llm 表逻辑兜底(向后兼容)。
|
||
- proxy_chat_completion(运行环境 token 路径)拿到真实 usage 后调 govern_settle 精算。
|
||
- llm_bridge 内部路径按 prompt/response 长度估算 token 调 govern_settle(note='est')。
|
||
|
||
限流:Redis 分钟窗口原子计数(db4,与会话 db3 隔离)。Redis 不可用时限流放行(fail-open,
|
||
记 warning)——限流失效不应阻断业务,余额检查仍在 DB 侧守住。
|
||
余额:读-校验-条件 UPDATE(WHERE balance>=amount),并发竞态由 Redis 预授权计数托底。
|
||
|
||
错误必须真实可行动(用户铁律):每一级失败返回明确的中文原因,前端原样展示。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
|
||
logger = logging.getLogger("pipeline_llm.gateway")
|
||
|
||
DBNAME = "pipeline"
|
||
GOVERN_REDIS_DB = 4 # 治理计数器命名空间(会话用 db3,互不干扰)
|
||
_COOLDOWN_SEC = 60 # 429 冷却窗口
|
||
_EST_TOKENS_PER_CALL = 800 # 历史常量(预授权已废弃,保留防外部引用)
|
||
|
||
# 治理开关缓存:机构是否启用治理(有策略记录或有 llm_model 记录)
|
||
_govern_cache: dict = {}
|
||
_cfg_cache: dict = {} # (account_id) -> {api_key 解密, base_url...}
|
||
|
||
|
||
# ────────────────────────── 基础设施 ──────────────────────────
|
||
|
||
def _get_db():
|
||
from sqlor.dbpools import DBPools
|
||
db = DBPools()
|
||
if not db.databases:
|
||
from appPublic.jsonConfig import getConfig
|
||
config = getConfig()
|
||
if config.databases:
|
||
db.databases = config.databases
|
||
return db, DBNAME
|
||
|
||
|
||
def _password_key():
|
||
from appPublic.jsonConfig import getConfig
|
||
try:
|
||
return getConfig().password_key or 'QRIVSRHrthhwyjy176556332'
|
||
except Exception:
|
||
return 'QRIVSRHrthhwyjy176556332'
|
||
|
||
|
||
def encrypt_api_key(plain: str) -> str:
|
||
"""AES 单层加密(对称可解密)。⚠️ 不用 RC4:password/unpassword 盐不对称,加密后不可解。"""
|
||
if not plain:
|
||
return ''
|
||
from appPublic.aes import aes_encode_b64
|
||
return aes_encode_b64(_password_key(), plain)
|
||
|
||
|
||
def decrypt_api_key(enc: str) -> str:
|
||
"""解密 api_key。非密文(历史明文/环境差异)原样返回。"""
|
||
if not enc:
|
||
return ''
|
||
try:
|
||
from appPublic.aes import aes_decode_b64
|
||
return aes_decode_b64(_password_key(), enc)
|
||
except Exception:
|
||
return enc
|
||
|
||
|
||
def _redis():
|
||
"""治理用 Redis 连接(db4)。从 session_redis 配置推导地址,不可用返回 None。"""
|
||
try:
|
||
import redis as _r
|
||
from appPublic.jsonConfig import getConfig
|
||
url = ''
|
||
try:
|
||
url = (getConfig().website or {}).get('session_redis', {}).get('url', '')
|
||
except Exception:
|
||
url = ''
|
||
if not url:
|
||
url = 'redis://127.0.0.1:6379/3'
|
||
# 替换库号:会话用配置的库,治理固定 GOVERN_REDIS_DB
|
||
base = url.rsplit('/', 1)[0]
|
||
return _r.Redis.from_url(base + '/' + str(GOVERN_REDIS_DB), socket_timeout=2)
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm: redis 不可用(限流降级放行): %s", e)
|
||
return None
|
||
|
||
|
||
def _row_to_dict(r):
|
||
"""行对象转 dict。sqlor 行是 DictObject(dict 子类),数据存在 dict 本体——
|
||
用 vars() 取的是空的实例 __dict__(2026-09-04 实测根因),必须 dict() 拷贝。"""
|
||
try:
|
||
return dict(r)
|
||
except (TypeError, ValueError):
|
||
return {k: v for k, v in vars(r).items() if not callable(v)}
|
||
|
||
|
||
def _parse_json(s, default):
|
||
if not s:
|
||
return default
|
||
try:
|
||
v = json.loads(s)
|
||
return v if v is not None else default
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _fnum(v):
|
||
try:
|
||
return float(v or 0)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
# ────────────────────────── 门禁 ①②:限流 ──────────────────────────
|
||
|
||
async def _rate_check(scope: str, scope_id: str, limit: int):
|
||
"""分钟窗口限流。返回 (ok, msg)。limit<=0 不限;Redis 故障放行。"""
|
||
if not limit or limit <= 0 or not scope_id:
|
||
return True, ''
|
||
r = _redis()
|
||
if r is None:
|
||
return True, ''
|
||
minute = int(time.time() // 60)
|
||
key = 'llm_rl:%s:%s:%d' % (scope, scope_id, minute)
|
||
try:
|
||
cnt = r.incr(key)
|
||
r.expire(key, 70)
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm: 限流计数失败(放行): %s", e)
|
||
return True, ''
|
||
if cnt > limit:
|
||
label = '个人' if scope == 'user' else '组织'
|
||
return False, '%s限流:本分钟已调用 %d 次(上限 %d),请下一分钟再试' % (label, cnt, limit)
|
||
return True, ''
|
||
|
||
|
||
# ────────────────────────── 门禁 ⑤⑥:策略选模型 + 候选轮转 ──────────────────────────
|
||
# 注:2026-09 记账重构废弃了组织池/个人额度预授权(原 _reserve_quota/_release_quota 已删除)——
|
||
# 本机构模型只记成本侧,owner 模型走异步三方账,防滥用由限流(①②)承担。
|
||
|
||
async def _org_policy(sor, org_id: str):
|
||
"""组织容错策略。无记录返回 (主模型空, 辅助模型空, 备链空, 端点偏好 any)。"""
|
||
if not org_id:
|
||
return '', '', [], 'any'
|
||
recs = await sor.sqlExe(
|
||
"SELECT primary_model_id, utility_model_id, backup_model_ids, endpoint_pref "
|
||
"FROM llm_org_policy WHERE org_id=${o}$ AND status='active' LIMIT 1", {"o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return '', '', [], 'any'
|
||
r = recs[0]
|
||
return (getattr(r, 'primary_model_id', '') or '',
|
||
getattr(r, 'utility_model_id', '') or '',
|
||
_parse_json(getattr(r, 'backup_model_ids', ''), []),
|
||
getattr(r, 'endpoint_pref', '') or 'any')
|
||
|
||
|
||
async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''):
|
||
"""解析模型链 [主, 备...],返回 llm_model 记录 dict 列表。
|
||
|
||
- 调用方指定 model_name:在 llm_model 找到 → [它] + **同能力**备链补充;
|
||
找不到 → 空(走旧表兜底)
|
||
- 未指定:策略主模型 + 备链;无策略 → 空
|
||
- purpose='utility'(辅助任务:分类/选择/摘要):链 = [辅助模型] + 主 + 备。
|
||
显式指定 model_name 时忽略 purpose(显式优先);未配辅助模型时等同普通链。
|
||
辅助模型不可用(账号/端点全挂)由 govern_resolve 的逐模型尝试顺延到主模型,
|
||
与主备容错同一机制,无新增降级路径。
|
||
|
||
⚠️ 同能力备链铁律(2026-09-07 qwen-image-plus 实测根因):显式指定 t2i
|
||
模型不可用时,旧逻辑把策略备链(清一色 t2t 对话模型)补进链里 → 静默
|
||
顺延到 deepseek 返回**文字**还报 ok:true——要图得文当成功,违反
|
||
「治理真实失败禁止静默回退」,且直接毒害 invoke_model 自动选型
|
||
(选了生图模型却拿到文本)。跨能力替换永远非法;同能力替代
|
||
(另一个文生图模型)才是合法容错。指定模型无同能力替代时链只剩
|
||
它自己,挑不到候选 → govern_resolve 报可行动错误(消息带最后原因)。
|
||
"""
|
||
chain_ids = []
|
||
primary, utility, backups, pref = await _org_policy(sor, org_id)
|
||
if model_name:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, capability FROM llm_model WHERE status='active' "
|
||
"AND (name=${n}$ OR vendor_model_id=${n}$) LIMIT 1",
|
||
{"n": model_name})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return [], pref
|
||
chain_ids.append(getattr(recs[0], 'id', ''))
|
||
want_cap = (getattr(recs[0], 'capability', '') or 't2t').strip().lower()
|
||
if backups:
|
||
# sqlor IN 列表必须展开占位符(传 list 会崩)
|
||
ph = ','.join('${b%d}$' % i for i in range(len(backups)))
|
||
bparams = {'b%d' % i: b for i, b in enumerate(backups)}
|
||
bparams['cap'] = want_cap
|
||
brecs = await sor.sqlExe(
|
||
"SELECT id FROM llm_model WHERE status='active' AND id IN (%s) "
|
||
"AND COALESCE(NULLIF(capability,''),'t2t')=${cap}$" % ph, bparams)
|
||
await sor.sqlExe("COMMIT", {})
|
||
for br in (brecs or []):
|
||
bid = getattr(br, 'id', '')
|
||
if bid and bid not in chain_ids:
|
||
chain_ids.append(bid)
|
||
else:
|
||
if purpose == 'utility' and utility:
|
||
chain_ids.append(utility)
|
||
if primary:
|
||
chain_ids.append(primary)
|
||
chain_ids.extend([b for b in backups if b not in chain_ids])
|
||
if not chain_ids:
|
||
return [], pref
|
||
models = []
|
||
for mid in chain_ids:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, name, vendor_id, vendor_model_id, capability, default_params, "
|
||
"ppid, org_id, profile_id, sync_mode, query_profile_ids, account_id "
|
||
"FROM llm_model WHERE id=${i}$ AND status='active' LIMIT 1", {"i": mid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs:
|
||
models.append(_row_to_dict(recs[0]))
|
||
return models, pref
|
||
|
||
|
||
async def _vendor_endpoints(sor, vendor_id: str):
|
||
"""供应商端点目录。返回 [endpoint dict]。
|
||
|
||
2026-09-05:供应商表不再有 protocol 字段——请求形态(协议/模式)由模型挂的
|
||
适配模板(llm_api_profile)决定,供应商只提供端点目录(账号/区域/超时)。
|
||
"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT endpoints FROM llm_vendor WHERE id=${i}$ AND status='active' LIMIT 1",
|
||
{"i": vendor_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return []
|
||
return _parse_json(getattr(recs[0], 'endpoints', ''), [])
|
||
|
||
|
||
async def _account_candidates(sor, model: dict):
|
||
"""模型的候选账号池:同供应商、启用中。
|
||
|
||
模型配置了 account_id 时(如某模型只允许用某个账号充值/密钥),
|
||
候选池收窄为该账号——模型级账号绑定优先于供应商级轮转。
|
||
"""
|
||
where = "vendor_id=${v}$ AND status='active'"
|
||
params = {"v": model.get('vendor_id', '')}
|
||
bound = (model.get('account_id') or '').strip()
|
||
if bound:
|
||
where += " AND id=${a}$"
|
||
params['a'] = bound
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, name, api_key, endpoint_ids, balance, status FROM llm_account "
|
||
"WHERE " + where, params)
|
||
await sor.sqlExe("COMMIT", {})
|
||
return [_row_to_dict(r) for r in (recs or [])]
|
||
|
||
|
||
def _filter_by_pref(candidates_with_ep, pref: str):
|
||
"""按端点偏好过滤/排序候选 (account, endpoint, idx) 列表。
|
||
|
||
must_*:只保留匹配区域,空则返回空(宁可冒泡不跨端点)。
|
||
prefer_*:匹配区域排前。any:原序。
|
||
"""
|
||
if pref in ('must_domestic', 'prefer_domestic'):
|
||
want = 'domestic'
|
||
elif pref in ('must_intl', 'prefer_intl'):
|
||
want = 'international'
|
||
else:
|
||
return candidates_with_ep
|
||
matched = [c for c in candidates_with_ep if (c[1].get('region') or '') == want]
|
||
if pref.startswith('must'):
|
||
return matched
|
||
rest = [c for c in candidates_with_ep if (c[1].get('region') or '') != want]
|
||
return matched + rest
|
||
|
||
|
||
async def _model_protocol(sor, model: dict) -> str:
|
||
"""模型适配模板的协议(2026-09-05 端点协议感知选择用)。查不到返回空串。"""
|
||
pid = (model.get('profile_id') or '').strip()
|
||
if not pid:
|
||
return ''
|
||
recs = await sor.sqlExe(
|
||
"SELECT protocol FROM llm_api_profile WHERE id=${i}$ LIMIT 1", {"i": pid})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return (getattr(recs[0], 'protocol', '') or '').strip() if recs else ''
|
||
|
||
|
||
async def _pick_candidate(sor, model: dict, pref: str):
|
||
"""构建候选并选一个:协议过滤 → 区域偏好过滤 → 冷却避让 → 余额加权。
|
||
|
||
返回 (ok, result):ok 时 result=(account_dict, endpoint_dict);否则 result=错误信息。
|
||
"""
|
||
vendor_id = model.get('vendor_id', '')
|
||
if not vendor_id:
|
||
return False, '模型「%s」未绑定供应商,无法选择账号' % model.get('name', '')
|
||
endpoints = await _vendor_endpoints(sor, vendor_id)
|
||
if not endpoints:
|
||
return False, '供应商端点目录为空(模型治理→供应商→端点目录),请配置端点'
|
||
accounts = await _account_candidates(sor, model)
|
||
if not accounts:
|
||
return False, '模型「%s」的供应商下无启用账号(或账号余额状态异常)' % model.get('name', '')
|
||
# 协议感知(2026-09-05 404 教训):模型模板协议与端点标签匹配才可用。
|
||
# 端点无标签(历史数据)视为通用——同供应商混布 compatible-mode/api/v1
|
||
# 两类端点时,无标签过滤会把原生异步模型拼到 openai_compat 前缀下 404。
|
||
mproto = await _model_protocol(sor, model)
|
||
if mproto:
|
||
tagged = [(i, e) for i, e in enumerate(endpoints) if (e.get('protocol') or '').strip()]
|
||
if tagged:
|
||
matched = {i for i, e in tagged if (e.get('protocol') or '').strip() == mproto}
|
||
untagged = {i for i, e in enumerate(endpoints)
|
||
if not (e.get('protocol') or '').strip()}
|
||
# 有协议匹配端点 → 只选匹配的(同账号多端点余额同分,按序会选到
|
||
# 第一个即 compatible-mode → 404,必须排除不匹配项而非并集);
|
||
# 无匹配但有未标注(历史)端点 → 兜底用未标注;全无 → 报错
|
||
if matched:
|
||
usable = matched
|
||
elif untagged:
|
||
usable = untagged
|
||
else:
|
||
return False, ('供应商端点均为其他协议(模型模板协议 %s,端点协议 %s)——'
|
||
'原生异步(openai_compat 之外)与兼容模式端点 base_url 不同,'
|
||
'混用必 404。请在供应商端点目录添加协议 %s 的端点'
|
||
% (mproto, sorted({(e.get('protocol') or '') for _, e in tagged}),
|
||
mproto))
|
||
else:
|
||
usable = set(range(len(endpoints)))
|
||
else:
|
||
usable = set(range(len(endpoints)))
|
||
# 展开 (账号, 端点) 对:账号选用的端点下标
|
||
pairs = []
|
||
for acc in accounts:
|
||
idxs = _parse_json(acc.get('endpoint_ids'), [])
|
||
if not idxs:
|
||
continue
|
||
for i in idxs:
|
||
try:
|
||
i = int(i)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if i in usable:
|
||
pairs.append((acc, endpoints[i], i))
|
||
if not pairs:
|
||
return False, ('所有账号都未选用可用端点(模型治理→供应商账号→选用端点)。'
|
||
'模型模板协议 %s——请把该协议端点加入账号的选用端点' % (mproto or '未知'))
|
||
pairs = _filter_by_pref(pairs, pref)
|
||
if not pairs:
|
||
return False, ('端点偏好为「仅 %s」,但没有可用的该区域端点。按合规要求不跨端点降级——'
|
||
'请配置该区域端点或调整组织策略的端点偏好' %
|
||
('国内' if 'domestic' in pref else '国际'))
|
||
# 冷却避让 + 余额加权选择
|
||
r = _redis()
|
||
best, best_score = None, -1.0
|
||
for acc, ep, i in pairs:
|
||
cool_key = 'llm_cool:%s:%d' % (acc.get('id', ''), i)
|
||
acct_key = 'llm_cool:%s:*' % (acc.get('id', ''),)
|
||
try:
|
||
if r is not None and (r.get(cool_key) or r.get(acct_key)):
|
||
continue # 限流冷却中(端点级或账号级)
|
||
except Exception:
|
||
pass
|
||
score = _fnum(acc.get('balance'))
|
||
if score > best_score:
|
||
best, best_score = (acc, ep, i), score
|
||
if best is None:
|
||
return False, '模型「%s」的全部候选(账号×端点)都在限流冷却中,请稍后重试' % model.get('name', '')
|
||
acc, ep, i = best
|
||
if _fnum(acc.get('balance')) <= 0:
|
||
return False, '模型「%s」的候选账号余额均不足,请为账号充值' % model.get('name', '')
|
||
return True, (acc, ep)
|
||
|
||
|
||
def _mark_cooldown(account_id: str, ep_index):
|
||
"""429 后把该账号(或账号×端点对)放进冷却。ep_index='*' 表示账号级。"""
|
||
r = _redis()
|
||
if r is None:
|
||
return
|
||
try:
|
||
r.set('llm_cool:%s:%s' % (account_id, ep_index), '1', ex=_COOLDOWN_SEC)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def mark_cooldown_by_pair(account_id: str, base_url: str):
|
||
"""供代理层 429 回调:按 base_url 找到端点下标进冷却(找不到则全部下标冷却)。"""
|
||
try:
|
||
# 代理层只拿得到 base_url;冷却键按下标,这里无法反查下标——
|
||
# 退化为账号级冷却(该账号所有端点),宁可保守
|
||
_mark_cooldown(account_id, '*')
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ────────────────────────── 对外主入口 ──────────────────────────
|
||
|
||
class GovernError(ValueError):
|
||
"""治理失败:消息真实可行动,前端原样展示。"""
|
||
|
||
|
||
async def governance_enabled(sor, org_id: str) -> bool:
|
||
"""机构是否启用治理:**策略即开关**——该机构有 active 的容错策略才启用。
|
||
|
||
⚠️ 不能用"新模型表有记录"判断(2026-09-01 修正):系统级(org_id=0)模型
|
||
注册后会让所有未配策略的机构被误判为启用,调用直接报错,断掉旧 llm 表链路。
|
||
机构显式建策略才是接入治理的动作;无策略 → 一切照旧(__LEGACY__)。
|
||
带缓存(改配置需重启,与 key 缓存一致)。
|
||
"""
|
||
if not org_id:
|
||
return False
|
||
if org_id in _govern_cache:
|
||
return _govern_cache[org_id]
|
||
enabled = False
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT COUNT(*) AS c FROM llm_org_policy WHERE org_id=${o}$ AND status='active'",
|
||
{"o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
enabled = (int(getattr(recs[0], 'c', 0)) if recs else 0) > 0
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm: governance_enabled 检查失败(按未启用): %s", e)
|
||
enabled = False
|
||
_govern_cache[org_id] = enabled
|
||
return enabled
|
||
|
||
|
||
async def _active_policy_org(sor, org_id: str) -> str:
|
||
"""确定生效策略机构:本机构有 active 策略 → 本机构;
|
||
否则平台 owner('0') 有 active 策略 → '0'(商业化默认:用平台资源即付费);
|
||
都没有 → ''(调用方回退旧 llm 表,零风险)。
|
||
"""
|
||
if org_id and org_id != '0':
|
||
recs = await sor.sqlExe(
|
||
"SELECT COUNT(*) AS c FROM llm_org_policy WHERE org_id=${o}$ AND status='active'",
|
||
{"o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs and int(_fnum(getattr(recs[0], 'c', 0))) > 0:
|
||
return org_id
|
||
recs = await sor.sqlExe(
|
||
"SELECT COUNT(*) AS c FROM llm_org_policy WHERE org_id='0' AND status='active'", {})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs and int(_fnum(getattr(recs[0], 'c', 0))) > 0:
|
||
return '0'
|
||
return ''
|
||
|
||
|
||
async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
||
est_tokens: int = 0, task_ref: str = '', purpose: str = ''):
|
||
"""门禁链 ①-⑥。
|
||
|
||
策略来源(2026-09 商业化默认):
|
||
本机构有策略 → 用本机构模型链;
|
||
本机构无策略 → 用平台 owner('0') 策略(用平台资源即付费);
|
||
owner 也没策略 → __LEGACY__ 旧表兜底。
|
||
本机构链路全不可用 → 容错顺延 owner 链(辅助→主→备同样机制)。
|
||
|
||
purpose='utility':辅助任务(分类/选择/摘要),链 = 辅助→主→备。显式 model_name 优先。
|
||
|
||
记账决策(2026-09):**不做组织池预授权**——
|
||
本机构模型只记成本侧(供应商账号扣减),不产生组织/个人消费;
|
||
owner 模型三方账(客户/商户/供应商)走异步产品计费链路。
|
||
防滥用第一道闸保留:限流。
|
||
|
||
返回 (ok, result):
|
||
ok=True → result = {api_base, api_key(明文,仅进程内), model_id, name,
|
||
account_id, endpoint_region, reserved(恒0),
|
||
model_row, user_id, org_id, task_ref, policy_org_id}
|
||
ok=False → result = 错误信息(真实可行动)
|
||
无任何策略 → (False, '__LEGACY__'):调用方走旧 llm 表逻辑
|
||
"""
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
policy_org = await _active_policy_org(sor, org_id or '')
|
||
if not policy_org:
|
||
return False, '__LEGACY__'
|
||
# ① 个人限流
|
||
if user_id:
|
||
recs = await sor.sqlExe(
|
||
"SELECT rate_limit FROM llm_user_quota WHERE user_id=${u}$ AND org_id=${o}$ "
|
||
"AND status='active' LIMIT 1", {"u": user_id, "o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
ulimit = int(_fnum(getattr(recs[0], 'rate_limit', 0))) if recs else 0
|
||
ok, msg = await _rate_check('user', user_id, ulimit)
|
||
if not ok:
|
||
return False, msg
|
||
# ② 组织限流
|
||
recs = await sor.sqlExe(
|
||
"SELECT rate_limit FROM llm_org_quota WHERE org_id=${o}$ AND status='active' LIMIT 1",
|
||
{"o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
olimit = int(_fnum(getattr(recs[0], 'rate_limit', 0))) if recs else 0
|
||
ok, msg = await _rate_check('org', org_id, olimit)
|
||
if not ok:
|
||
return False, msg
|
||
# ⑤ 模型链(含 ⑥ 偏好)—— 显式 model_name 时全局查找
|
||
models, pref = await _model_chain(sor, policy_org, model_name or '', purpose)
|
||
if not models:
|
||
if model_name:
|
||
return False, '__LEGACY__' # 指定模型不在新表 → 旧表兜底
|
||
return False, ('生效策略未配置模型:请在模型治理→组织容错策略配置主/备模型,'
|
||
'或在调用时指定模型名')
|
||
# ⑤.5 模型归属硬校验(2026-09-07 用户需求):只有平台 owner('0'/系统级共享)
|
||
# 或本机构的模型可调用;他机构模型从链中剔除,链内替代模型(策略备链)自然顺延。
|
||
# 显式指定的模型被剔除且链内无替代 → 报可行动错误(禁止静默回退/越权放行)。
|
||
from .selection import _owner_allowed
|
||
allowed = [m for m in models if _owner_allowed(m.get('org_id'), org_id)]
|
||
if len(allowed) != len(models):
|
||
for m in models:
|
||
if not _owner_allowed(m.get('org_id'), org_id):
|
||
logger.info("pipeline_llm: 模型 %s 归属机构 %s,调用方 %s 无权使用,"
|
||
"已从模型链剔除(顺延替代模型)",
|
||
m.get('name'), m.get('org_id'), org_id or '(未指定)')
|
||
if not allowed:
|
||
if model_name:
|
||
return False, ('模型「%s」归属其他机构(非平台 owner 共享、也非本机构模型),'
|
||
'不允许调用。请选择本机构或平台共享模型'
|
||
'(模型治理→模型注册可查看归属)' % model_name)
|
||
return False, ('生效策略模型链全部归属其他机构,调用方 %s 无可调用模型——'
|
||
'请检查机构策略配置' % (org_id or '(未指定)'))
|
||
models = allowed
|
||
# ⑥ 逐模型尝试(辅助 → 主 → 备容错)
|
||
last_err = ''
|
||
chosen = None
|
||
for m in models:
|
||
ok, cand = await _pick_candidate(sor, m, pref)
|
||
if ok and isinstance(cand, tuple):
|
||
chosen = (m, cand)
|
||
break
|
||
last_err = cand if isinstance(cand, str) else last_err
|
||
logger.info("pipeline_llm: 模型 %s 候选不可用(%s),尝试链内下一模型", m.get('name'), cand)
|
||
# 本机构链全不可用 → 容错落 owner 链
|
||
if chosen is None and policy_org != '0':
|
||
models0, pref0 = await _model_chain(sor, '0', model_name or '', purpose)
|
||
# owner 容错链同样过归属校验(策略里可能配了他机构注册的模型)
|
||
models0 = [m for m in models0 if _owner_allowed(m.get('org_id'), org_id)]
|
||
# 同能力铁律(2026-09-07):显式指定模型时 owner 容错链也只许
|
||
# 同能力替代——要图给文的静默回退在任何一层都不合法。
|
||
if model_name and models0:
|
||
_want = (models0[0].get('capability') or 't2t').strip().lower()
|
||
models0 = [m for m in models0
|
||
if (m.get('capability') or 't2t').strip().lower() == _want]
|
||
for m in models0:
|
||
ok, cand = await _pick_candidate(sor, m, pref0)
|
||
if ok and isinstance(cand, tuple):
|
||
chosen = (m, cand)
|
||
policy_org = '0'
|
||
pref = pref0
|
||
logger.info("pipeline_llm: 本机构模型链不可用,容错使用平台 owner 模型 %s",
|
||
m.get('name'))
|
||
break
|
||
last_err = cand if isinstance(cand, str) else last_err
|
||
if chosen is None:
|
||
return False, '模型链全部不可用。最后原因:%s' % last_err
|
||
model, chosen_pair = chosen
|
||
acc, ep = chosen_pair
|
||
# ⑦ 组装结果(无预授权,reserved 恒 0;记账在 govern_settle 按来源分流)
|
||
return True, {
|
||
'api_base': (ep.get('base_url') or '').rstrip('/'),
|
||
'api_key': decrypt_api_key(acc.get('api_key') or ''),
|
||
'model_id': model.get('vendor_model_id') or model.get('name', ''),
|
||
'name': model.get('name', ''),
|
||
'account_id': acc.get('id', ''),
|
||
'endpoint_region': ep.get('region') or '',
|
||
'endpoint_proxy': ep.get('proxy') or '',
|
||
'timeout': int(_fnum(ep.get('timeout')) or 60),
|
||
'reserved': 0.0,
|
||
'model_row': model,
|
||
'user_id': user_id or '',
|
||
'org_id': org_id or '',
|
||
'task_ref': task_ref or '',
|
||
'policy_org_id': policy_org,
|
||
}
|
||
|
||
|
||
async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_tokens: int = 0,
|
||
note: str = '', usages: dict = None):
|
||
"""结算 ⑧:成本侧扣减 + 用量流水(三态记账状态机)。
|
||
|
||
ctx govern_resolve 成功时返回的 dict(含 policy_org_id)
|
||
ok_call 上游调用是否成功(失败也记账:写 failed 流水)
|
||
usages 非 token 用量因子(JSON 落 llm_usage.usages):视频时长/分辨率、
|
||
图像张数等按量计费模型的计价因子,由异步出账读入定价引擎
|
||
(与 req_tokens/resp_tokens 并列,定价引擎按 pricing_data 选用)。
|
||
|
||
accounting_status 三态(2026-09-05 用户定夺;2026-09-06 补 NULL 语义):
|
||
created —— 已创建、待记账(仅 status='SUCCEEDED' 的行,出账循环拾取)
|
||
accounted —— 记账成功
|
||
failed —— 记账失败
|
||
NULL —— 无记账意义:status≠SUCCEEDED 的行(FAILED/PENDING/RUNNING/recharge)
|
||
一律保持 NULL,不写 created(用户定夺:只有 SUCCEEDED 才有记账的意义)
|
||
status 统一枚举(2026-09-06 用户定夺):SUCCEEDED/FAILED/PENDING/RUNNING(其他=recharge)。
|
||
自用 vs 跨机构的分流不在这里——product_accounting_generic 内部按
|
||
is_self_use 决定记账项(自用只记 PAY* 采购成本,跨机构另记 PAY 客户应付),
|
||
所有成功调用统一 created 进出账队列,成本账不遗漏。
|
||
"""
|
||
if not ctx or not isinstance(ctx, dict):
|
||
return
|
||
db, dbname = _get_db()
|
||
try:
|
||
model = ctx.get('model_row') or {}
|
||
# 2026-09-04:模型表不再存单价/成本四字段——成本与售价统一走 ppid 定价引擎,
|
||
# 出账由异步记账循环(accounting.py)经产品层计算;此处 cost 恒 0,
|
||
# 供应商账号余额不再按调用扣减(真实成本以定价引擎结算为准)。
|
||
cost = 0.0
|
||
caller_org = ctx.get('org_id') or ''
|
||
async with db.sqlorContext(dbname) as sor:
|
||
if not ok_call:
|
||
# 上游限流(429)失败 → 账号级冷却,轮转避让下一轮不再选它
|
||
if '429' in str(note or ''):
|
||
_mark_cooldown(ctx.get('account_id', ''), '*')
|
||
else:
|
||
# 成本侧:供应商账号余额扣减(本机构/owner 模型都扣——真实钱流)
|
||
if ctx.get('account_id') and cost > 0:
|
||
await sor.sqlExe(
|
||
"UPDATE llm_account SET balance=balance-${c}$ WHERE id=${i}$",
|
||
{"c": cost, "i": ctx.get('account_id', '')})
|
||
await sor.sqlExe("COMMIT", {})
|
||
from appPublic.uniqueID import getID
|
||
usages_str = ''
|
||
if usages:
|
||
try:
|
||
usages_str = json.dumps(usages, ensure_ascii=False, default=str)[:2000]
|
||
except Exception:
|
||
usages_str = ''
|
||
# status 统一枚举(2026-09-06 用户定夺):SUCCEEDED/FAILED;
|
||
# accounting_status 只在 SUCCEEDED 时写 created,否则缺省 NULL
|
||
# (只有 SUCCEEDED 才有记账意义——失败/充值行挂 created 曾被误报"待记账")
|
||
usage_row = {
|
||
'id': getID(),
|
||
'org_id': caller_org,
|
||
'user_id': ctx.get('user_id', ''),
|
||
'model_id': model.get('id', ''),
|
||
'account_id': ctx.get('account_id', ''),
|
||
'endpoint_region': ctx.get('endpoint_region', ''),
|
||
'req_tokens': int(req_tokens or 0),
|
||
'resp_tokens': int(resp_tokens or 0),
|
||
'cost': round(cost, 6),
|
||
'charge': 0, # 客户应付由异步出账计算(自用为 0,跨机构按折扣)
|
||
'ppid': model.get('ppid', '') or '',
|
||
'task_ref': ctx.get('task_ref', ''),
|
||
'status': 'SUCCEEDED' if ok_call else 'FAILED',
|
||
'usages': usages_str,
|
||
'note': (note or '')[:200],
|
||
}
|
||
if ok_call:
|
||
usage_row['accounting_status'] = 'created'
|
||
await sor.C('llm_usage', usage_row)
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception as e:
|
||
logger.warning("pipeline_llm: 结算失败(不阻断调用方): %s", e)
|
||
|
||
|
||
# ────────────────────────── 充值 ──────────────────────────
|
||
|
||
async def recharge_account(account_id: str, amount, note: str = '', operator: str = ''):
|
||
"""供应商账号充值(成本侧钱包)。返回 (ok, msg)。"""
|
||
try:
|
||
amount = float(amount)
|
||
except (TypeError, ValueError):
|
||
return False, '充值金额必须是数字'
|
||
if amount <= 0:
|
||
return False, '充值金额必须大于 0'
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe("SELECT id FROM llm_account WHERE id=${i}$", {"i": account_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return False, '账号不存在'
|
||
await sor.sqlExe(
|
||
"UPDATE llm_account SET balance=balance+${a}$, total_recharge=total_recharge+${a}$ "
|
||
"WHERE id=${i}$", {"a": amount, "i": account_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await _write_recharge_usage(sor, '', '', account_id, amount, note, operator)
|
||
_govern_cache.clear()
|
||
return True, '账号充值 %.4f 成功' % amount
|
||
|
||
|
||
async def recharge_org(org_id: str, amount, note: str = '', operator: str = ''):
|
||
"""组织池充值(消费侧钱包)。无记录自动创建。返回 (ok, msg)。"""
|
||
try:
|
||
amount = float(amount)
|
||
except (TypeError, ValueError):
|
||
return False, '充值金额必须是数字'
|
||
if amount <= 0:
|
||
return False, '充值金额必须大于 0'
|
||
if not org_id:
|
||
return False, '缺少机构 ID'
|
||
db, dbname = _get_db()
|
||
async with db.sqlorContext(dbname) as sor:
|
||
recs = await sor.sqlExe("SELECT id FROM llm_org_quota WHERE org_id=${o}$", {"o": org_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if recs:
|
||
await sor.sqlExe(
|
||
"UPDATE llm_org_quota SET balance=balance+${a}$, total_recharge=total_recharge+${a}$ "
|
||
"WHERE org_id=${o}$", {"a": amount, "o": org_id})
|
||
else:
|
||
from appPublic.uniqueID import getID
|
||
await sor.C('llm_org_quota', {
|
||
'id': getID(), 'org_id': org_id,
|
||
'balance': amount, 'total_recharge': amount,
|
||
'rate_limit': 0, 'status': 'active'})
|
||
await sor.sqlExe("COMMIT", {})
|
||
await _write_recharge_usage(sor, org_id, '', '', amount, note, operator)
|
||
_govern_cache.clear()
|
||
return True, '组织池充值 %.4f 成功' % amount
|
||
|
||
|
||
async def _write_recharge_usage(sor, org_id, user_id, account_id, amount, note, operator):
|
||
"""充值流水(llm_usage 里 status=recharge 的行,对账用)。"""
|
||
from appPublic.uniqueID import getID
|
||
await sor.C('llm_usage', {
|
||
'id': getID(),
|
||
'org_id': org_id or '',
|
||
'user_id': user_id or '',
|
||
'model_id': '',
|
||
'account_id': account_id or '',
|
||
'endpoint_region': '',
|
||
'req_tokens': 0,
|
||
'resp_tokens': 0,
|
||
'cost': round(float(amount), 6) if account_id else 0,
|
||
'charge': round(float(amount), 6) if org_id else 0,
|
||
'ppid': '',
|
||
'task_ref': 'recharge',
|
||
'status': 'recharge',
|
||
'note': ((note or '') + (' | 操作人:%s' % operator if operator else ''))[:200],
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
|
||
def load_gateway():
|
||
"""注册治理函数到 ServerEnv(供 dspy/其他模块调用)。"""
|
||
from ahserver.serverenv import ServerEnv
|
||
env = ServerEnv()
|
||
env.llm_govern_resolve = govern_resolve
|
||
env.llm_govern_settle = govern_settle
|
||
env.llm_recharge_account = recharge_account
|
||
env.llm_recharge_org = recharge_org
|
||
env.llm_encrypt_api_key = encrypt_api_key
|
||
logger.info("[pipeline_llm] gateway loaded")
|