mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:41:59 +00:00
7255ae98a5
- 「不指定接入」交给设置(核查模型、裁判模型)时:启用的接入全是演示类,设置这一级不管写的是什么,执行时落到的都是 演示模型,说明照实带上「(演示用,不调用真实模型)」、候选带 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、裁判格式检查、规划开销随节点数线性增长(数试算时算了多少处 取模型的地方);其余几个文件跟上新说法。新加的核心用例都做过去掉实现、测试变红的验证。
215 lines
12 KiB
Python
215 lines
12 KiB
Python
"""找模型接入(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。请到「设置 → 模型接入」检查")
|