diff --git a/pipeline_llm/gateway.py b/pipeline_llm/gateway.py index ea6be1c..15664ad 100644 --- a/pipeline_llm/gateway.py +++ b/pipeline_llm/gateway.py @@ -169,24 +169,47 @@ async def _org_policy(sor, org_id: str): async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): """解析模型链 [主, 备...],返回 llm_model 记录 dict 列表。 - - 调用方指定 model_name:在 llm_model 找到 → [它] + 策略备链补充;找不到 → 空(走旧表兜底) + - 调用方指定 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 FROM llm_model WHERE status='active' AND (name=${n}$ OR vendor_model_id=${n}$) LIMIT 1", + "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', '')) - chain_ids.extend([b for b in backups if b not in chain_ids]) + 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) @@ -514,9 +537,15 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '', 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', '', purpose) + 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):