mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
点名的模型接入不存在或已停用时照实报错,不再悄悄换成演示模型
resolve_provider 以前在显式点名的接入找不到时返回 None,get_chat_model 把它当成「一个接入都没配」, 返回演示模型(mock-fast):节点上、设置里把接入名写错一个字,或者接入改了名、删了,报告就由演示模型 写成假文本,核查也由它来核,正式运行照样出具。点名一个已停用的接入又照常返回、照常调用,和按模型名 找到停用接入就报错的规则不一致。 现在点名的接入不存在抛 ProviderNotConfigured(code=missing,「模型接入「X」不存在」,提示去设置里核对 名称或换一个接入),已停用抛 code=disabled(说法和按模型名找到停用接入时一致)。判断收在 named_provider 一处,设置校验和画布校验也按它认。没点名时的回退照旧:一个启用的接入都没有才用演示 模型,模型名认不出来退回第一个启用的真实接入。只有空白的接入名算没点名。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user