Files
AgentLab/backend/tests/test_catalog_draft_settings.py

274 lines
13 KiB
Python
Raw Permalink 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.
"""起草模型(设置里的 catalog_draft 一组)与起草取模型的顺序。
- 设置组 catalog_draft:{provider, model},经 PUT /api/settings 写、GET 读,没存过补 {provider: null, model: null};
provider 不是字符串、点名的接入不存在或已停用 → 422,说法写设置页上的叫法「起草模型 · 接入」「起草模型 · 模型」
- 取模型的顺序(数据目录起草和按配方导入的 AI 起草共用 draft_model.draft_model_choice):起草模型填了接入或模型就整组
用它;否则沿用助手的模型设置;再没有就是默认接入。不跨级拼接入和模型
- 数据目录起草不关思考(按接入默认)、额度 32768,服务方嫌大退回 8192;配方 AI 起草只改取模型的顺序,额度、思考不动
接入名、模型名都是虚构的。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.data import catalog, draft_model
from app.data import recipe_ai as A
from app.db.base import SessionLocal
from app.db.models import Provider, Setting
from app.main import app
LIVE, OFF, GONE = "起草用接入", "起草用停用接入", "起草用不存在的接入"
async def _clean() -> None:
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([draft_model.CATALOG_DRAFT_SETTING_KEY])))
await session.execute(delete(Provider).where(Provider.name.in_([LIVE, OFF])))
await session.commit()
@pytest.fixture
async def providers():
"""一个启用、一个停用的演示类接入(不联网);用完删掉。"""
await _clean()
async with SessionLocal() as session:
session.add_all([
Provider(name=LIVE, kind="mock", models=[{"id": "起草模型甲"}], default_model="起草模型甲", enabled=True,
extra={}),
Provider(name=OFF, kind="mock", models=[{"id": "起草模型乙"}], default_model="起草模型乙", enabled=False,
extra={}),
])
await session.commit()
yield
await _clean()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
# --------------------------------------------------------------------------
# 设置组的读写与校验
# --------------------------------------------------------------------------
async def test_the_group_is_exposed_with_empty_defaults(client, providers):
got = (await client.get("/api/settings")).json()
assert got["catalog_draft"] == {"provider": None, "model": None}
async def test_the_group_round_trips(client, providers):
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": LIVE, "model": "起草模型甲"}}})
assert r.status_code == 200, r.text
assert r.json()["catalog_draft"] == {"provider": LIVE, "model": "起草模型甲"}
got = (await client.get("/api/settings")).json()["catalog_draft"]
assert got == {"provider": LIVE, "model": "起草模型甲"}
# 只写模型、接入留空:照存,接入是 null
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": " ", "model": "x"}}})
assert r.status_code == 200, r.text
assert r.json()["catalog_draft"] == {"provider": None, "model": "x"}
@pytest.mark.parametrize("value,label", [
({"provider": 3}, "起草模型 · 接入"),
({"model": ["a"]}, "起草模型 · 模型"),
({"temperature": 0}, "temperature"),
("起草模型甲", "起草模型"),
])
async def test_bad_values_are_refused_with_the_settings_page_wording(client, providers, value, label):
# 比的是这次请求前后:同一个测试进程里别的测试可能已经把主题存成了别的值,不能假定它不是 dark
before = (await client.get("/api/settings")).json()["ui"]["theme"]
wanted = "light" if before == "dark" else "dark"
r = await client.put("/api/settings", json={"values": {"catalog_draft": value, "ui": {"theme": wanted}}})
assert r.status_code == 422, r.text
assert label in r.json()["detail"]
got = (await client.get("/api/settings")).json()
assert got["catalog_draft"] == {"provider": None, "model": None} and got["ui"]["theme"] == before
@pytest.mark.parametrize("name,reason,fix", [
(GONE, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称"),
(OFF, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它"),
])
async def test_a_missing_or_disabled_provider_is_refused_with_the_field(client, providers, name, reason, fix):
r = await client.put("/api/settings", json={"values": {"catalog_draft": {"provider": name}}})
assert r.status_code == 422, r.text
body = r.json()
assert body["code"] == "settings_provider_invalid" and body["field"] == "catalog_draft.provider"
assert body["detail"] == f"「起草模型 · 接入」指定的{reason}。{fix},或换一个接入"
async with SessionLocal() as session:
assert await session.get(Setting, "catalog_draft") is None
# --------------------------------------------------------------------------
# 取模型的顺序
# --------------------------------------------------------------------------
class _MemDB:
"""独立的内存库:取模型只看这里的接入和设置,不受别的测试建的接入影响。"""
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 mem():
async with _MemDB() as session:
yield session
@pytest.fixture
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 _setup(session, *, draft: dict | None = None, copilot: dict | None = None) -> None:
from app.core.crypto import encrypt
for name, model in (("甲接入", "模型甲"), ("乙接入", "模型乙"), ("丙接入", "模型丙")):
session.add(Provider(name=name, kind="anthropic", api_key=encrypt("sk-test"), models=[{"id": model}],
default_model=model, enabled=True, extra={}))
if draft is not None:
session.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value=draft))
if copilot is not None:
session.add(Setting(key=draft_model.COPILOT_SETTING_KEY, value=copilot))
await session.commit()
@pytest.mark.parametrize("draft,copilot,expected", [
(None, None, (None, None, "default")),
(None, {"provider": "乙接入", "model": ""}, ("乙接入", None, "assistant")),
({"provider": None, "model": None}, {"provider": "", "model": "模型乙"}, (None, "模型乙", "assistant")),
({"provider": "", "model": "模型丙"}, {"provider": "乙接入", "model": "模型乙"}, (None, "模型丙", "draft")),
({"provider": "甲接入", "model": None}, {"provider": "乙接入", "model": "模型乙"}, ("甲接入", None, "draft")),
({"provider": 7}, {"provider": "乙接入"}, ("乙接入", None, "assistant")), # 存坏了的按没填
])
async def test_choice_order(mem, draft, copilot, expected):
await _setup(mem, draft=draft, copilot=copilot)
got = await draft_model.draft_model_choice(mem)
assert (got.provider, got.model, got.origin) == expected
def _spy(monkeypatch) -> list[Any]:
from app.providers import factory
specs: list[Any] = []
class Built:
def __init__(self, provider, spec):
self.provider, self.spec = provider.name, spec
def fake_build(provider, spec):
specs.append((provider.name, spec))
return Built(provider, spec)
monkeypatch.setattr(factory, "build_chat_model", fake_build)
return specs
async def test_catalog_draft_uses_the_draft_setting_keeps_thinking_and_a_big_budget(mem, no_env_keys, monkeypatch):
await _setup(mem, draft={"provider": "甲接入", "model": None}, copilot={"provider": "乙接入", "model": "模型乙"})
specs = _spy(monkeypatch)
model, model_id = await catalog.resolve_draft_model(mem)
assert model_id == "模型甲"
assert [p for p, _ in specs] == ["甲接入", "甲接入"]
assert [s.max_tokens for _, s in specs] == [catalog.MODEL_MAX_TOKENS, catalog.MODEL_FALLBACK_MAX_TOKENS]
assert catalog.MODEL_MAX_TOKENS == 32768 and catalog.MODEL_FALLBACK_MAX_TOKENS == 8192
assert all(s.thinking is None and s.temperature == 0 for _, s in specs), "不关思考,按接入默认"
assert all(s.timeout == catalog.MODEL_TIMEOUT_S == 420 for _, s in specs)
assert all(s.max_retries == 0 for _, s in specs), "重试只由起草引擎按失败原因来做,SDK 自己不再重试"
assert model.budget == 32768 and model.model_name == "模型甲"
@pytest.mark.parametrize("kind,extra", [
("openai_compatible", {}),
("anthropic", {}),
("anthropic", {"auth_style": "bearer"}),
], ids=["openai-compatible", "anthropic", "anthropic-bearer"])
def test_the_built_model_does_not_retry_on_its_own_when_asked(kind, extra):
"""ModelSpec.max_retries 一直传到 SDK 客户端(Bearer 鉴权换上的客户端也是);不写就照 SDK 默认。"""
from app.core.crypto import encrypt
from app.providers.factory import ModelSpec, build_chat_model
provider = Provider(name="虚构接入", kind=kind, api_key=encrypt("sk-test"), base_url="https://gateway.example/v1",
models=[], default_model="model-x", extra=extra)
def client_retries(chat: Any) -> int:
client = chat.root_async_client if kind == "openai_compatible" else chat._async_client
return client.max_retries
assert client_retries(build_chat_model(provider, ModelSpec(model="model-x", max_retries=0))) == 0
assert client_retries(build_chat_model(provider, ModelSpec(model="model-x"))) == 2
async def test_catalog_draft_falls_back_to_the_assistant_model(mem, no_env_keys, monkeypatch):
await _setup(mem, copilot={"provider": "", "model": "模型乙"})
specs = _spy(monkeypatch)
_, model_id = await catalog.resolve_draft_model(mem)
assert model_id == "模型乙" and specs[0][0] == "乙接入"
async def test_recipe_ai_draft_follows_the_same_order_but_keeps_its_own_spec(mem, no_env_keys, monkeypatch):
await _setup(mem, draft={"provider": "", "model": "模型丙"}, copilot={"provider": "乙接入", "model": "模型乙"})
specs = _spy(monkeypatch)
model, model_id, avail = await A.resolve_draft_model(mem)
assert avail.available and model_id == "模型丙" and avail.provider == "丙接入"
assert [s.max_tokens for _, s in specs] == [A.AI_MAX_TOKENS, A.AI_FALLBACK_MAX_TOKENS]
assert all(s.thinking == "off" and s.temperature == 0 for _, s in specs), "配方 AI 起草的思考设置不动"
assert all(s.max_retries is None for _, s in specs), "配方 AI 起草的重试照旧(SDK 默认)"
async def test_a_broken_draft_setting_says_where_to_fix_it(mem, no_env_keys):
"""起草模型点名的接入不存在:报错说的是起草模型,不是助手的模型。沿用助手模型时照旧说助手。"""
from app.core.crypto import encrypt
mem.add(Provider(name="停用的接入", kind="anthropic", api_key=encrypt("k"), models=[{"id": "模型丁"}],
default_model="模型丁", enabled=False, extra={}))
mem.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value={"provider": "停用的接入"}))
await mem.commit()
with pytest.raises(catalog.CatalogModelUnavailable) as e:
await catalog.resolve_draft_model(mem)
assert "起草模型" in str(e.value) and "停用的接入" in str(e.value) and "已停用" in str(e.value)
assert "助手" not in str(e.value)
_, _, avail = await A.resolve_draft_model(mem)
assert not avail.available and "起草模型" in avail.reason and "助手" not in avail.reason
await mem.execute(delete(Setting))
mem.add(Setting(key=draft_model.COPILOT_SETTING_KEY, value={"provider": "停用的接入"}))
await mem.commit()
with pytest.raises(catalog.CatalogModelUnavailable) as e:
await catalog.resolve_draft_model(mem)
assert "助手使用的" in str(e.value)
async def test_only_a_demo_provider_is_refused(mem, no_env_keys):
mem.add(Provider(name="演示", kind="mock", models=[{"id": "mock-fast"}], default_model="mock-fast", enabled=True,
extra={}))
await mem.commit()
with pytest.raises(catalog.CatalogModelUnavailable) as e:
await catalog.resolve_draft_model(mem)
assert "演示" in str(e.value)