Files
AgentLab/backend/tests/test_provider_resolution.py
T
ThearchyHelios 7255ae98a5 模型接入报错收尾(第 1 轮复核,后端):交给设置的核查、裁判落到演示模型照实说;给不出修复时说卡在哪里;接入名恰好是模型名那句按这一处检查器里有哪几栏写;修复规划只重算被改的节点;助手栏那一格另给一份说法;几处措辞对齐
- 「不指定接入」交给设置(核查模型、裁判模型)时:启用的接入全是演示类,设置这一级不管写的是什么,执行时落到的都是
  演示模型,说明照实带上「(演示用,不调用真实模型)」、候选带 demo 标记;一个启用的接入都没有时不算落得稳(同别处
  「换成内置演示模型」不算)。以前 land() 在判演示之前就把交给设置的一处当成落稳返回了;
- 检查器里没有下拉框、发布前检查又给不出修复时,提示末尾补「无法改用其他接入:」加第一处落不稳的地方(Landing.why,
  比如「成员「研究员」指定的模型「小模型」所属的模型接入「旧接入」已停用」),不再只剩「核对名称」让人去设置里找一个
  找不到的名字;
- 点名的接入名恰好是某个启用接入的模型:画布、发布前检查按这一处在检查器里有哪几栏写(provider_refs._model_note)——
  模型调用一类在节点的「模型」中选择它,接入在发布前检查里改用这个接入或选择「不指定接入」(修复里有哪项说哪项);
  协作成员、全图默认只说可在发布前检查里改用;核查节点、报告自己的结论句裁判两栏都在,照设置页写这两栏的叫法。
  不再叫人去一个没有的「接入」栏留空。运行时报错不分节点类型,「填写」改为「选择它」;
- 修复候选修完以后落到演示类接入的带 demo 标记,发布弹窗据此在选项名旁加醒目的标签;
- 性能:改一个节点的配置只影响它自己取模型的那几处,修复规划(autofix._ProviderPlan)每试一个候选只重算这个节点
  (provider_refs.node_uses),不再深拷贝、重算整张图;问题提示只问给不给得出(provider_fixable,找到第一个落得稳的
  就停);接入问题的引用整张图只算一遍。60 个节点都点名同一个停用接入时,画布校验里这一部分约 0.35 秒降到约 0.002 秒,
  发布前检查约 0.72 秒降到约 0.01 秒,一次应用 30 条修复约 2.7 秒降到约 0.09 秒;
- 助手:GET /api/copilot/model 另给 panel_error(写在助手栏「助手使用的模型」那一格下面,去向写「上方」,不把人指回这个
  下拉框本身);接入名恰好是模型名时并成一句,不再两次提「助手使用的模型」;PUT /api/settings 里助手那一项的 422 用
  助手报错那一句(那里只有一个选模型的下拉框,没有「换一个接入」的地方);
- 裁判设置的格式检查写「留空表示跟随助手的模型」,同设置页空选项(engine/judge.py 一行,同核查那条的改法);「交给助手」
  后只剩接入问题时写「助手无法处理」,有修复时另起半句「也可在各条问题下方选择改用的模型接入」。

测试:test_provider_ref_wording 加演示落在设置上、demo 标记、给不出修复时卡在哪里(含一个启用接入都没有)、各处的
接入名恰好是模型名、助手栏那一格的说法、助手那一项的 422、裁判格式检查、规划开销随节点数线性增长(数试算时算了多少处
取模型的地方);其余几个文件跟上新说法。新加的核心用例都做过去掉实现、测试变红的验证。
2026-10-06 19:43:44 +08:00

215 lines
12 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.
"""找模型接入(providers/factory.resolve_provider、get_chat_model):点名了就只用点名的那个。
以前显式点名的接入不存在时返回 None,get_chat_model 把它当成「一个接入都没配」,悄悄换成演示模型(mock-fast):
节点上、设置里把接入名写错一个字,或者接入改了名、删了,报告就由演示模型写成假文本,运行照样成功。点名一个已停用的
接入又照常返回、照常调用,和「按模型名找到的接入已停用就报错」对不上。
现在:
- 点名的接入不存在 → ProviderNotConfigured(missing),说清是哪个名字、去「设置 → 模型接入」核对;
- 点名的接入已停用 → ProviderNotConfigured(disabled),说法和按模型名找到停用接入时一致;
- 只有没点名、一个启用的接入都没有时才回退到演示模型(没配 key 的机器上图也能执行完);没点名、模型名认不出来时
照旧退回第一个启用的真实接入(用户可能手填了目录里还没有的新模型)。
每个用例一个独立的内存库,接入名、模型名都是虚构的。
"""
from __future__ import annotations
from typing import Any
import pytest
from app.providers import factory
from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model, resolve_provider
from app.providers.mock_model import MockChatModel
class _MemDB:
"""独立的内存库:不碰测试会话共用的库,也不受别的测试建的接入影响(同 test_recipe_ai)。"""
async def __aenter__(self):
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
import app.db.models # noqa: F401 - 建表前要先登记全部模型
from app.db.base import Base
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
self.session = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)()
return self.session
async def __aexit__(self, *exc):
await self.session.close()
await self.engine.dispose()
@pytest.fixture
async def session():
async with _MemDB() as s:
yield s
@pytest.fixture(autouse=True)
def no_env_keys(monkeypatch):
for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_BASE_URL"):
monkeypatch.delenv(k, raising=False)
async def add(session, name: str, *, kind: str = "openai_compatible", models: tuple[str, ...] = ("m-1",),
enabled: bool = True, **extra: Any):
from app.core.crypto import encrypt
from app.db.models import Provider
row = Provider(name=name, kind=kind, base_url="http://127.0.0.1:9/v1" if kind == "openai_compatible" else None,
api_key=encrypt("k-test"), models=[{"id": m} for m in models],
default_model=models[0] if models else None, enabled=enabled, extra={}, **extra)
session.add(row)
await session.commit()
return row
# --------------------------------------------------------------------------
# 点名了就只用点名的那个
# --------------------------------------------------------------------------
async def test_a_named_provider_that_does_not_exist_is_an_error_not_the_mock(session):
await add(session, "甲接入")
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, "甲接人") # 写错一个字
assert e.value.code == "missing"
assert e.value.reason == "模型接入「甲接人」不存在"
assert str(e.value) == "模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或换一个接入"
with pytest.raises(ProviderNotConfigured) as e:
await get_chat_model(session, ModelSpec(provider="甲接人", model="m-1"))
assert e.value.code == "missing"
async def test_a_named_provider_is_missing_even_when_no_provider_exists_at_all(session):
"""一个接入都没有、却点了名:点名的那个仍然不存在,不能因为「什么都没配」就退回演示模型。"""
with pytest.raises(ProviderNotConfigured) as e:
await get_chat_model(session, ModelSpec(provider="已删除的接入"))
assert e.value.code == "missing" and "已删除的接入" in str(e.value)
async def test_a_named_provider_that_is_disabled_is_an_error_worded_like_the_model_lookup(session):
off = await add(session, "停用接入", models=("m-off",), enabled=False)
await add(session, "备用接入", models=("m-on",))
for ref in ("停用接入", off.id):
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, ref, "m-off")
assert e.value.code == "disabled"
assert str(e.value) == "模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或换一个接入"
# 和按模型名找到停用接入时同一种说法:「已停用」「启用它」
with pytest.raises(ProviderNotConfigured) as by_model:
await resolve_provider(session, None, "m-off")
assert by_model.value.code == "disabled"
for words in ("已停用", "请在「设置 → 模型接入」中启用它"):
assert words in str(by_model.value) and words in str(e.value)
async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one_serving_that_model(session):
"""开发数据里真有一条停用的接入,名字恰好就是一个模型 id:点名时撞上它要照实报停用,不能悄悄换成提供这个模型的那一家;
没点名、只写了模型名时照常按模型名找。"""
await add(session, "fake-v4.1-flash", models=("fake-v4.1-flash",), enabled=False)
serving = await add(session, "云端接入", models=("fake-v4.1-flash", "fake-v4-pro"))
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, "fake-v4.1-flash", "fake-v4.1-flash")
assert e.value.code == "disabled" and "fake-v4.1-flash" in str(e.value)
# 照「启用它」去做启用的是那条旧接入:另补一句这个名字是哪个接入的模型、怎么用它
assert e.value.model_owner == "云端接入" and str(e.value).endswith(
"「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」")
assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id
async def test_a_named_provider_is_found_by_name_or_id_and_used_as_is(session):
house = await add(session, "本地接入", kind="mock", models=("mock-fast",))
await add(session, "云端接入", models=("m-cloud",))
assert (await resolve_provider(session, "本地接入")).id == house.id
assert (await resolve_provider(session, house.id)).id == house.id
assert (await resolve_provider(session, " 本地接入 ")).id == house.id # 两边的空白不算
# 点名一个演示接入是明确的选择,照用
model, model_id = await get_chat_model(session, ModelSpec(provider="本地接入"))
assert isinstance(model, MockChatModel) and model_id == "mock-fast"
async def test_a_blank_name_counts_as_not_named(session):
cloud = await add(session, "云端接入")
for ref in ("", " ", None):
assert (await resolve_provider(session, ref)).id == cloud.id
# --------------------------------------------------------------------------
# 没点名:保留原来的回退
# --------------------------------------------------------------------------
async def test_without_any_enabled_provider_and_no_name_the_mock_still_runs(session):
"""没配 key 的机器上图也能执行完:没点名、一个启用的接入都没有,才回退到演示模型。停用的接入不算。"""
model, model_id = await get_chat_model(session, ModelSpec())
assert isinstance(model, MockChatModel) and model_id == "mock-fast"
await add(session, "停用接入", models=("m-off",), enabled=False)
model, model_id = await get_chat_model(session, ModelSpec(model="认不出的模型"))
assert isinstance(model, MockChatModel) and model_id == "mock-fast"
async def test_without_a_name_an_unknown_model_goes_to_the_first_real_provider(session):
"""手填了目录里还没有的新模型:照旧退回第一个启用的真实接入(演示接入排在后面)。"""
await add(session, "演示", kind="mock", models=("mock-fast",))
real = await add(session, "云端接入", models=("m-cloud",))
assert (await resolve_provider(session, None, "刚发布的新模型")).id == real.id
assert (await resolve_provider(session, None, None)).id == real.id
async def test_without_a_name_the_model_picks_its_own_provider(session):
await add(session, "甲接入", models=("m-a",))
b = await add(session, "乙接入", models=("m-b",))
assert (await resolve_provider(session, None, "m-b")).id == b.id
async def test_get_chat_model_builds_the_named_provider(session, monkeypatch):
"""点名的接入找得到、启用着:照常建模型,用的就是它。"""
target = await add(session, "乙接入", models=("m-b",))
await add(session, "甲接入", models=("m-a",))
built: list[Any] = []
def spy(provider, spec):
built.append((provider.id, spec.model))
return MockChatModel(model_name=spec.model or "")
monkeypatch.setattr(factory, "build_chat_model", spy)
_, model_id = await get_chat_model(session, ModelSpec(provider="乙接入", model="m-b"))
assert built == [(target.id, "m-b")] and model_id == "m-b"
async def test_a_name_that_is_not_text_counts_as_named_and_missing(session):
"""接口写进设置里的数字一类:以前 resolve_provider 在这里抛 AttributeError(查助手模型的接口跟着 500),
再往前是当成没给、悄悄回退。现在按点名了一个不存在的接入报。"""
await add(session, "云端接入")
for ref in (123, 0):
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, ref) # type: ignore[arg-type]
assert e.value.code == "missing" and e.value.reason == f"模型接入「{ref}」不存在"
async def test_the_assistant_is_told_which_provider_is_broken(session):
"""助手(改图、表格导入、数据目录起草)用的模型取不到时的那句话:点名的接入不存在、已停用,按模型名找到的接入
已停用,照实说是哪个、在设置里能做什么;接入本身没配完(缺 key)仍说「尚未配置完成」。"""
from app.providers.factory import assistant_unavailable
await add(session, "停用接入", models=("m-off",), enabled=False)
said = {}
for case, ref, model in (("missing", "甲接人", None), ("disabled", "停用接入", None), ("by_model", None, "m-off")):
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, ref, model)
said[case] = assistant_unavailable(e.value)
repick = "或在「编排」页助手栏的「助手使用的模型」中重新选择"
assert said["missing"] == f"助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,{repick}"
assert said["disabled"] == f"助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,{repick}"
assert said["by_model"] == ("助手使用的模型「m-off」所属的模型接入「停用接入」已停用。"
f"请在「设置 → 模型接入」中启用它,{repick}")
keyless = ProviderNotConfigured("模型接入「云端接入」没有配置 API Key", "请在接入中填写", code="missing_key")
assert assistant_unavailable(keyless) == (
"助手使用的模型尚未配置完成:模型接入「云端接入」没有配置 API Key。请到「设置 → 模型接入」检查")