diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 0f7e3cee..742f3ea5 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -34,7 +34,7 @@ class ModelSpec(BaseModel): class ProviderNotConfigured(RuntimeError): """模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句, 运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。 - code 给调用方按类别分支:disabled / no_model / missing_key / missing_base_url。""" + code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。""" def __init__(self, reason: str, hint: str = "", code: str = "") -> None: super().__init__(f"{reason}。{hint}" if hint else reason) @@ -48,6 +48,22 @@ async def list_providers(session: AsyncSession) -> list[Provider]: return list(rows.scalars()) +def named_provider(providers: list[Provider], ref: str) -> Provider: + """按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛 + ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。 + 名称两边的空白不算。""" + key = ref.strip() + found = next((p for p in providers if key in (p.id, p.name)), None) \ + or next((p for p in providers if (p.name or "").strip() == key), None) + if found is None: + raise ProviderNotConfigured(f"模型接入「{key}」不存在", + "请在「设置 → 模型接入」中核对名称,或换一个接入", code="missing") + if not found.enabled: + raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", + "请在「设置 → 模型接入」中启用它,或换一个接入", code="disabled") + return found + + async def resolve_provider( session: AsyncSession, ref: str | None, model: str | None = None ) -> Provider | None: @@ -56,15 +72,18 @@ async def resolve_provider( 顺序很关键:显式指定的 ref 最优先;其次按模型名反查 —— 节点上通常只选了 模型(比如 mock-fast),没选 provider,这时必须找到真正提供该模型的那一个, 否则会拿着 A 家的模型名去调 B 家的接口。最后才回退到默认 provider。 + + 显式给了 ref 却不存在或已停用,抛 ProviderNotConfigured(named_provider)。以前不存在时返回 None, + get_chat_model 把它当成「一个接入都没配」,悄悄换成演示模型:名字写错一个字、接入改了名或删了, + 报告就由演示模型写成假文本,核查也由它来「核」,运行照样成功。停用的照常返回、照常调用, + 又和下面「按模型名找到的接入已停用就报错」对不上。 + 返回 None 只剩一种情况:没给 ref(只有空白的也算没给),而一个启用的接入都没有。 """ providers = await list_providers(session) enabled = [p for p in providers if p.enabled] - if ref: - for p in providers: - if p.name == ref or p.id == ref: - return p - return None + if ref and ref.strip(): + return named_provider(providers, ref) if model: for p in enabled: @@ -362,8 +381,11 @@ def bind_tools_safely(model: BaseChatModel, tools: list[Any], *, parallel: bool) async def get_chat_model(session: AsyncSession, spec: ModelSpec) -> tuple[BaseChatModel, str]: - """返回 (模型, 实际使用的模型 id)。找不到任何 provider 时回退到 mock, - 保证一张图在没配 key 的机器上也能跑完。""" + """返回 (模型, 实际使用的模型 id)。 + + 只有没点名接入、而一个启用的接入都没有时才回退到演示模型,保证一张图在没配 key 的机器上也能执行完。 + 点名的接入不存在或已停用、按模型名找到的接入已停用、接入缺 key 这些都抛 ProviderNotConfigured, + 由调用方照实报给人(节点失败、核查记缺口……),不换成演示模型。""" provider = await resolve_provider(session, spec.provider, spec.model) if provider is None: return MockChatModel(model_name="mock-fast"), "mock-fast" diff --git a/backend/tests/test_provider_resolution.py b/backend/tests/test_provider_resolution.py new file mode 100644 index 00000000..3a02ee0d --- /dev/null +++ b/backend/tests/test_provider_resolution.py @@ -0,0 +1,180 @@ +"""找模型接入(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 (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"