From 65369b8a6e9366cc3caab25c29a6f12c39a3164e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 08:23:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E5=8A=A9=E6=89=8B=EF=BC=9ASDK=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E9=87=8D=E8=AF=95=EF=BC=88?= =?UTF-8?q?=E5=90=8C=E8=B5=B7=E8=8D=89=EF=BC=8C=E7=BD=91=E7=BB=9C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E3=80=81429=E3=80=815xx=20=E5=8F=AA=E7=94=B1=E8=A1=A8?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E5=9C=A8=E8=BF=98=E6=B2=A1=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E9=87=8D=E8=AF=95=EF=BC=8C=E9=80=80=E9=81=BF=203?= =?UTF-8?q?=E3=80=8110=20=E7=A7=92=EF=BC=89=EF=BC=9B=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E5=8F=96=E6=A8=A1=E5=9E=8B=E7=9A=84=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=EF=BC=88=E8=B5=B7=E8=8D=89=E8=AE=BE=E7=BD=AE=E3=80=81?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=80=9D=E8=80=83=E3=80=81=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=2032768=20/=208192=E3=80=81=E5=8D=95=E6=AC=A1=E6=97=B6?= =?UTF-8?q?=E9=99=90=E3=80=81=E4=B8=8D=E9=87=8D=E8=AF=95=EF=BC=89=E5=92=8C?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=E5=B7=A5=E5=85=B7=E5=9C=A8=20OpenAI=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E3=80=81Anthropic=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8A=E9=83=BD=E7=BB=91=E5=BE=97=E4=B8=8A=EF=BC=9Bquery=5Fs?= =?UTF-8?q?napshot=5Ftables=20=E4=B8=8D=E8=BF=9B=20catalog=20=E7=9A=84=20?= =?UTF-8?q?=5F=5Fall=5F=5F=EF=BC=88=E6=8C=89=E6=A8=A1=E5=9D=97=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E5=8F=96=E7=94=A8=EF=BC=8C=E5=B0=91=E5=92=8C=E8=B5=B7?= =?UTF-8?q?=E8=8D=89=E9=82=A3=E8=BE=B9=E7=9A=84=E6=94=B9=E5=8A=A8=E6=92=9E?= =?UTF-8?q?=E5=9C=A8=E4=B8=80=E8=B5=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/catalog.py | 3 +- backend/app/data/catalog_assistant.py | 7 +-- backend/tests/test_catalog_assistant.py | 63 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py index 40bdaab8..36d481d3 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -2351,8 +2351,7 @@ __all__ = [ "fk_relations", "frozen_catalog", "infer_name_relations", "initial_status", "is_date_column", "is_profile_note", "join_paths", "make_item", "merge_notes", "name_words", "now_iso", "parse_path", "plan_patch", "profile_relation_holds", - "query_snapshot_meta", "query_snapshot_tables", "read_catalog", "read_entry", "relation_graph", "relation_id", - "render_table_index", + "query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id", "render_table_index", "render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry", "review_item", "same_notes", "same_profile_note", "save_human_edit", "save_patch", "status_counts", "structure_relation_ids", "system_notes_source", "table_usage", "unreviewed_status", "validate_notes", "visible_notes", "write_entry", diff --git a/backend/app/data/catalog_assistant.py b/backend/app/data/catalog_assistant.py index 475a6a5e..f8ac5e4e 100644 --- a/backend/app/data/catalog_assistant.py +++ b/backend/app/data/catalog_assistant.py @@ -75,8 +75,8 @@ QUERY_ROWS = 50 SHOWN_ROWS = 10 CELL_MAX = 300 TOOL_CHARS = 12_000 -#: 网络错误、429、5xx 重试前等几秒(依次取用,个数就是最多重试几次) -RETRY_DELAYS_S = (2.0, 5.0) +#: 网络错误、429、5xx 重试前等几秒(依次取用,个数就是最多重试几次)。在对话里等,比起草(10、30 秒)短 +RETRY_DELAYS_S = (3.0, 10.0) #: 等模型的时候每隔几秒发一行 SSE 注释 HEARTBEAT_S = 10.0 #: 背景里「目录里还没有中文名和含义的列」最多列几个;别的表指过来的关联关系最多列几条 @@ -257,8 +257,9 @@ async def resolve_model(session: AsyncSession) -> tuple[Any, str]: provider, choice = await resolve_draft_provider(session, none_reason=ERROR_NO_MODEL, mock_reason=ERROR_NO_MODEL) except DraftModelUnavailable as e: raise catalog.CatalogModelUnavailable(str(e)) from e + # SDK 自己不重试(同起草):网络错误、429、5xx 由这里在还没输出时重试,两层叠起来实际次数就对不上了 spec = ModelSpec(provider=choice.provider, model=choice.model, max_tokens=catalog.MODEL_MAX_TOKENS, temperature=0, - timeout=int(catalog.MODEL_TIMEOUT_S)) + timeout=int(catalog.MODEL_TIMEOUT_S), max_retries=0) try: big = build_chat_model(provider, spec) small = build_chat_model(provider, spec.model_copy(update={"max_tokens": catalog.MODEL_FALLBACK_MAX_TOKENS})) diff --git a/backend/tests/test_catalog_assistant.py b/backend/tests/test_catalog_assistant.py index 2d268bb0..89f29bd8 100644 --- a/backend/tests/test_catalog_assistant.py +++ b/backend/tests/test_catalog_assistant.py @@ -228,6 +228,69 @@ async def test_unavailable_draft_model_is_409_with_the_draft_wording(client, tmp assert r.json()["code"] == "catalog_draft_model_unavailable" and "模型接入" in r.json()["detail"] +@pytest.fixture +async def mem(monkeypatch): + """单独的内存库:接入和设置只在这个用例里。""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + from sqlalchemy.pool import StaticPool + + from app.db.base import Base + + for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_BASE_URL"): + monkeypatch.delenv(key, raising=False) + engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=StaticPool) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)() + yield session + await session.close() + await engine.dispose() + + +async def _provider(session, kind: str) -> None: + from app.core.crypto import encrypt + from app.data import draft_model + from app.db.models import Provider, Setting + + session.add(Provider(name="起草网关", kind=kind, base_url="http://gw.example.test/v1" if kind != "anthropic" else None, + api_key=encrypt("sk-test"), models=[{"id": "模型甲"}], default_model="模型甲", enabled=True, + extra={})) + session.add(Setting(key=draft_model.CATALOG_DRAFT_SETTING_KEY, value={"provider": "起草网关", "model": None})) + await session.commit() + + +async def test_model_follows_the_draft_setting_keeps_thinking_and_a_big_budget(mem, monkeypatch): + from app.providers import factory + + await _provider(mem, "openai_compatible") + specs: list[Any] = [] + real = factory.build_chat_model + + def spy(provider, spec): + specs.append(spec) + return real(provider, spec) + + monkeypatch.setattr(factory, "build_chat_model", spy) + model, model_id = await catalog_assistant.resolve_model(mem) + assert model_id == "模型甲" and model.max_tokens == catalog.MODEL_MAX_TOKENS == 32768 + mine = specs[-2:] # 前两份是 catalog.resolve_draft_model 查能不能用时建的 + assert [s.max_tokens for s in mine] == [catalog.MODEL_MAX_TOKENS, catalog.MODEL_FALLBACK_MAX_TOKENS] + assert all(s.thinking is None and s.temperature == 0 and s.max_retries == 0 for s in mine) + assert all(s.timeout == catalog.MODEL_TIMEOUT_S for s in mine) + + +@pytest.mark.parametrize("kind", ["openai_compatible", "anthropic"]) +async def test_tools_bind_on_real_chat_models(mem, kind): + """两个工具的定义各家的模型都收(只绑定,不发请求)。""" + await _provider(mem, kind) + model, _ = await catalog_assistant.resolve_model(mem) + tools = [catalog_assistant.QUERY_TOOL_PROMPT, catalog_assistant.PROPOSE_TOOL_PROMPT] + bound = model.bind_tools(tools) + given = bound.model.kwargs["tools"] + names = [t.get("name") or t["function"]["name"] for t in given] + assert names == ["run_query", "propose_changes"] + + # -------------------------------------------------------------------------- # 背景 # --------------------------------------------------------------------------