diff --git a/pipeline_llm/gateway.py b/pipeline_llm/gateway.py index 15664ad..262af00 100644 --- a/pipeline_llm/gateway.py +++ b/pipeline_llm/gateway.py @@ -201,10 +201,18 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): # 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)} + # 能力降级(2026-09-08):备链允许同能力或合法降级源能力 + # (t2i→i2i、t2v→r2v);跨能力(如 t2t)仍永远非法 + from .selection import CAP_DEGRADE_SOURCE + _alt = CAP_DEGRADE_SOURCE.get(want_cap, '') + if _alt: + ph += ',${altcap}$' + bparams['altcap'] = _alt 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) + _sql = ("SELECT id FROM llm_model WHERE status='active' AND id IN (%s) " + "AND COALESCE(NULLIF(capability,''),'t2t') IN (${cap}$" + + (",${altcap}$" if _alt else "") + ")") % ph + brecs = await sor.sqlExe(_sql, bparams) await sor.sqlExe("COMMIT", {}) for br in (brecs or []): bid = getattr(br, 'id', '') @@ -542,10 +550,14 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '', models0 = [m for m in models0 if _owner_allowed(m.get('org_id'), org_id)] # 同能力铁律(2026-09-07):显式指定模型时 owner 容错链也只许 # 同能力替代——要图给文的静默回退在任何一层都不合法。 + # 能力降级(2026-09-08):合法降级源能力(t2i→i2i、t2v→r2v)除外。 if model_name and models0: + from .selection import CAP_DEGRADE_SOURCE _want = (models0[0].get('capability') or 't2t').strip().lower() + _alt = CAP_DEGRADE_SOURCE.get(_want, '') models0 = [m for m in models0 - if (m.get('capability') or 't2t').strip().lower() == _want] + if (m.get('capability') or 't2t').strip().lower() + in (_want, _alt)] for m in models0: ok, cand = await _pick_candidate(sor, m, pref0) if ok and isinstance(cand, tuple): diff --git a/pipeline_llm/selection.py b/pipeline_llm/selection.py index 1e65d53..d83e5aa 100644 --- a/pipeline_llm/selection.py +++ b/pipeline_llm/selection.py @@ -23,6 +23,26 @@ logger = logging.getLogger("pipeline_llm.selection") # 会话 agent 模型下拉只列这些;embedding/rerank/图视频生成等形态各有专属入口。 CHAT_CAPS = ('t2t', 'i2t', 'm2t') +# 能力降级表(2026-09-08 用户定夺,与 r2v→t2v 同例):请求能力无可用模型时, +# 允许降级用「超集能力」模型执行任务。方向 = 降级目标模型的输入契约覆盖 +# 请求能力: +# i2i(图像→图像,接受文本提示+可选图)可执行 t2i(文本→图像)任务 +# r2v(参考媒体→视频,接受文本+参考媒体)可执行 t2v(文本→视频)任务 +# 单向降级:t2i 模型不能接 i2i(缺源图)、t2v 不能接 r2v——反向永远非法。 +CAP_DEGRADE = {'t2i': 'i2i', 't2v': 'r2v'} +# 反向索引:降级源能力 → 请求能力(门禁契约换算用:i2i 模型无图输入时按 t2i 契约校验) +CAP_DEGRADE_SOURCE = {v: k for k, v in CAP_DEGRADE.items()} + + +def degrade_expand(caps): + """能力白名单 + 其合法降级源能力(候选池扩展用)。无映射时原样返回。""" + out = [str(c).strip().lower() for c in (caps or ()) if str(c or '').strip()] + for c in list(out): + d = CAP_DEGRADE.get(c) + if d and d not in out: + out.append(d) + return tuple(out) + def _norm_caps(capabilities): """capabilities 参数归一:'chat' 哨兵 → CHAT_CAPS;逗号串 → tuple; @@ -178,6 +198,10 @@ async def models_catalog(org_id, capabilities=(), limit=50): """ db, dbname = _get_sor() caps = _norm_caps(capabilities) + # 能力降级(2026-09-08):候选池含请求能力的合法降级源模型 + # (t2i→i2i、t2v→r2v),无可用 t2i 模型时 i2i 模型可承接文生图任务 + if caps: + caps = degrade_expand(caps) async with db.sqlorContext(dbname) as sor: sql = ("SELECT m.id, m.name, m.vendor_model_id, m.capability, m.description, " "m.org_id, v.name AS vendor_name " @@ -249,7 +273,10 @@ async def auto_select_model(org_id, user_input, user_id='', capabilities='chat') '(格式:模型名 [能力类型] 供应商:描述):\n%s\n\n' '能力类型含义:t2t=文本对话 i2t=图像理解 m2t=多媒体理解 t2i=文生图 ' 'i2v=图生视频 t2v=文生视频 r2v=参考生视频 tts=语音合成 asr=语音识别 ' - 'embedding=向量化 rerank=重排序\n\n' + 'embedding=向量化 rerank=重排序\n' + '能力降级规则:无 t2i 模型时 i2i(图生图)模型可承接文生图任务;' + '无 t2v 模型时 r2v(参考生视频)模型可承接文生视频任务;' + '反向(t2i 接图生图、t2v 接参考生视频)非法。\n\n' '用户任务:\n%s\n\n' '请根据任务的内容领域、语言、复杂度,从目录中选出最适合完成该任务的模型' '(只能选目录中的模型名,按适合度从高到低排序,最多 3 个)。'