747 lines
35 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.

"""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_settlenote='est')。
限流Redis 分钟窗口原子计数db4与会话 db3 隔离。Redis 不可用时限流放行fail-open
记 warning——限流失效不应阻断业务余额检查仍在 DB 侧守住。
余额:读-校验-条件 UPDATEWHERE 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 单层加密(对称可解密)。⚠️ 不用 RC4password/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 行是 DictObjectdict 子类),数据存在 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")