Files

278 lines
14 KiB
Python

"""Model-specific administrator instructions: persistence, authorization and calls."""
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import Field
from app.gateway.routers import system_settings as router
from deerflow.config import system_settings as ss
from deerflow.models.prompt_suffix import append_model_prompt_suffix, with_model_prompt_suffix
@pytest.fixture(autouse=True)
def runtime(monkeypatch, tmp_path):
monkeypatch.setattr(ss, "runtime_home", lambda: tmp_path)
def save(prompt="请直接回答问题", enabled=True):
settings = ss.load_system_settings()
settings.model_prompt_suffixes["provider/model-a"] = ss.ModelPromptSuffix(enabled=enabled, prompt=prompt)
ss.save_system_settings(settings)
def test_matching_order_and_no_state_mutation():
save()
messages = [SystemMessage(content="基础要求"), HumanMessage(content="问题"), SystemMessage(content="补充上下文")]
assert append_model_prompt_suffix(messages, "model-b") is messages
result = append_model_prompt_suffix(messages, "provider/model-a")
assert result[0].content == "基础要求\n\n---\n\n补充上下文\n\n请直接回答问题"
assert result[1:] == [messages[1]]
assert messages[0].content == "基础要求"
assert len(messages) == 3
assert append_model_prompt_suffix(result, "provider/model-a") == result
def test_blocks_no_system_and_disabled():
save()
original = SystemMessage(content=[{"type": "text", "text": "原始要求"}])
result = append_model_prompt_suffix([original], "provider/model-a")
assert result[0].content[-1]["text"] == "\n\n请直接回答问题"
assert len(original.content) == 1
assert append_model_prompt_suffix(result, "provider/model-a") == result
assert append_model_prompt_suffix([HumanMessage(content="问题")], "provider/model-a")[0].content == "请直接回答问题"
save(enabled=False)
assert append_model_prompt_suffix([original], "provider/model-a") == [original]
save(prompt=" ")
assert append_model_prompt_suffix([original], "provider/model-a") == [original]
class RecordingModel(FakeListChatModel):
seen: list = Field(default_factory=list)
def _generate(self, messages, *args, **kwargs):
self.seen.append(messages)
return super()._generate(messages, *args, **kwargs)
def _stream(self, messages, *args, **kwargs):
self.seen.append(messages)
yield from super()._stream(messages, *args, **kwargs)
async def _astream(self, messages, *args, **kwargs):
self.seen.append(messages)
async for chunk in super()._astream(messages, *args, **kwargs):
yield chunk
@pytest.mark.asyncio
async def test_all_invocation_paths_and_live_updates():
model = with_model_prompt_suffix(RecordingModel)(responses=["ok"], deerflow_prompt_model_name="provider/model-a")
messages = [SystemMessage(content="base"), HumanMessage(content="question")]
save()
model.invoke(messages)
await model.ainvoke(messages)
list(model.stream(messages))
_ = [chunk async for chunk in model.astream(messages)]
model.generate([messages])
await model.agenerate([messages])
model.bind(temperature=0).invoke(messages)
assert len(model.seen) == 7
assert all(batch[0].content == "base\n\n请直接回答问题" for batch in model.seen)
save(prompt="新的要求")
model.invoke(messages)
assert model.seen[-1][0].content == "base\n\n新的要求"
save(enabled=False)
model.invoke(messages)
assert model.seen[-1][0].content == "base"
assert messages[0].content == "base"
assert "deerflow_prompt_model_name" not in model.model_dump()
def test_per_model_instance_switch_skips_suffix():
save()
model = with_model_prompt_suffix(RecordingModel)(
responses=["ok"],
deerflow_prompt_model_name="provider/model-a",
deerflow_prompt_suffix_enabled=False,
)
model.invoke([SystemMessage(content="base"), HumanMessage(content="question")])
assert model.seen[-1][0].content == "base"
assert "deerflow_prompt_suffix_enabled" not in model.model_dump()
def test_factory_preserves_provider_and_tool_binding(monkeypatch):
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.models.factory import create_chat_model
from deerflow.models.patched_openai import PatchedChatOpenAI
config = AppConfig(sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"}, models=[ModelConfig(name="provider/model-a", model="test", use="langchain_openai:ChatOpenAI", api_key="test", base_url="http://localhost:9999/v1")])
model = create_chat_model(app_config=config)
assert isinstance(model, PatchedChatOpenAI)
save()
bound = model.bind_tools([{"type": "function", "function": {"name": "test", "parameters": {"type": "object", "properties": {}}}}])
payload = bound.bound._get_request_payload([HumanMessage(content="hello")], **bound.kwargs)
assert payload["messages"][0]["content"] == "请直接回答问题"
assert payload["tools"][0]["function"]["name"] == "test"
assert "deerflow_prompt_model_name" not in payload
responses_model = create_chat_model(app_config=config, use_responses_api=True)
responses_payload = responses_model._get_request_payload([SystemMessage(content="base"), HumanMessage(content="hello")])
system_content = responses_payload["input"][0]["content"]
assert "请直接回答问题" in str(system_content)
def test_admin_api_validation_and_persistence(monkeypatch):
app = FastAPI()
app.include_router(router.router)
client = TestClient(app)
user = SimpleNamespace(system_role="user")
async def current_user(_request):
return user
monkeypatch.setattr(router, "get_optional_user_from_request", current_user)
monkeypatch.setattr(router, "get_app_config", lambda: SimpleNamespace(get_model_config=lambda name: object() if name == "provider/model-a" else None))
endpoint = "/api/system-settings/model-prompt-suffixes"
url = endpoint + "/provider%2Fmodel-a"
assert client.get(endpoint).status_code == 403
assert client.put(url, json={"enabled": True, "prompt": "要求"}).status_code == 403
user.system_role = "admin"
assert client.put(url, json={"enabled": True, "prompt": " "}).status_code == 422
assert client.put(url, json={"enabled": True, "prompt": "x" * 20001}).status_code == 422
assert client.put(endpoint + "/unknown", json={"enabled": True, "prompt": "要求"}).status_code == 404
settings = ss.load_system_settings()
settings.conversation_context.max_conversation_rounds = 12
ss.save_system_settings(settings)
assert client.put(url, json={"enabled": True, "prompt": "要求"}).status_code == 200
assert client.get(endpoint).json()["provider/model-a"] == {"enabled": True, "prompt": "要求"}
assert ss.load_system_settings().conversation_context.max_conversation_rounds == 12
user = None
assert client.get(endpoint).status_code == 401
assert client.put(url, json={"enabled": False, "prompt": ""}).status_code == 401
@pytest.mark.asyncio
@pytest.mark.parametrize("custom_agent", [False, True], ids=["ordinary-chat", "agent-chat"])
@pytest.mark.parametrize("agent_prompt_suffix_enabled", [True, False], ids=["agent-suffix-on", "agent-suffix-off"])
@pytest.mark.parametrize("selected", [None, "provider/model-a", "model-b"], ids=["default-model", "selected-target", "selected-other"])
@pytest.mark.parametrize("streaming", [False, True], ids=["invoke", "stream"])
async def test_chat_graph_final_http_payload(monkeypatch, custom_agent, agent_prompt_suffix_enabled, selected, streaming):
"""Run both lead-agent branches through LangGraph to the actual HTTP boundary.
Replace external skill/sandbox middleware with a late context injector, and
use distinguishable base prompt fixtures. Model selection, graph execution,
factory, provider serialization and HTTP transport remain real.
"""
import json
import httpx
from langchain.agents.middleware import AgentMiddleware
from deerflow.agents.lead_agent import agent as lead
from deerflow.agents.lead_agent import prompt as prompts
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.models.factory import create_chat_model
save()
captured = []
def respond(request):
payload = json.loads(request.content)
captured.append(payload)
if payload.get("stream"):
chunks = [
{"id": "chat-test", "object": "chat.completion.chunk", "model": "test", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}, "finish_reason": None}]},
{"id": "chat-test", "object": "chat.completion.chunk", "model": "test", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
]
content = "".join("data: " + json.dumps(chunk) + "\n\n" for chunk in chunks) + "data: [DONE]\n\n"
return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=content)
return httpx.Response(200, json={"id": "chat-test", "object": "chat.completion", "model": "test", "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}]})
class LateContext(AgentMiddleware):
async def awrap_model_call(self, request, handler):
return await handler(
request.override(
system_message=SystemMessage(content=request.system_message.content + "\n最后一个中间件的要求"),
messages=[*request.messages, SystemMessage(content="运行时检索上下文")],
)
)
config = AppConfig(
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
models=[ModelConfig(name=name, model="test", use="langchain_openai:ChatOpenAI", api_key="test", base_url="http://model.test/v1") for name in ["model-b", "provider/model-a"]],
)
agent_config = SimpleNamespace(name="测试智能体", model="provider/model-a", model_prompt_suffix_enabled=agent_prompt_suffix_enabled, skills=[], tool_groups=None) if custom_agent else None
monkeypatch.setattr(lead, "load_agent_config", lambda _: agent_config)
monkeypatch.setattr(lead, "load_agent_soul", lambda _: "SOUL.md 的要求" if custom_agent else "")
monkeypatch.setattr(lead, "apply_prompt_template", lambda **_: "普通问答模板")
monkeypatch.setattr(prompts, "apply_agent_only_prompt_template", lambda **_: "SOUL.md 的要求")
monkeypatch.setattr(lead, "_build_middlewares", lambda *args, **kwargs: [LateContext()])
runtime = {"disable_tools": True, "thinking_enabled": False, "agent_id": "test-agent" if custom_agent else "lead"}
if selected:
runtime["model_name"] = selected
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
monkeypatch.setattr(lead, "create_chat_model", lambda **kwargs: create_chat_model(**kwargs, http_async_client=client))
graph = lead._make_lead_agent({"configurable": runtime}, app_config=config)
inputs = {"messages": [HumanMessage(content="用户问题")]}
if streaming:
_ = [chunk async for chunk in graph.astream(inputs, stream_mode="messages")]
else:
await graph.ainvoke(inputs)
assert len(captured) == 1
messages = captured[0]["messages"]
systems = [message for message in messages if message["role"] in {"system", "developer"}]
assert len(systems) == 1
text = systems[0]["content"]
assert ("SOUL.md 的要求" if custom_agent else "普通问答模板") in text
assert "最后一个中间件的要求" in text
assert "运行时检索上下文" in text
effective_model = selected or ("provider/model-a" if custom_agent else "model-b")
if effective_model == "provider/model-a" and (not custom_agent or agent_prompt_suffix_enabled):
assert text.endswith("运行时检索上下文\n\n请直接回答问题")
assert text.count("请直接回答问题") == 1
else:
assert "请直接回答问题" not in text
assert [message["content"] for message in messages if message["role"] == "user"] == ["用户问题"]
assert inputs["messages"][0].content == "用户问题"
@pytest.mark.parametrize("transport", ["text_bridge", "buffered_gateway", "artifact_renderer"])
def test_suffix_follows_file_transport_instructions(transport):
from deerflow.models import patched_openai as provider
save()
model = with_model_prompt_suffix(provider.PatchedChatOpenAI)(
model="test",
api_key="test",
deerflow_prompt_model_name="provider/model-a",
)
tool = {"type": "function", "function": {"name": "write_file", "description": "Write a file", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}}}
tool["function"]["parameters"]["properties"].update({"description": {"type": "string"}, "append": {"type": "boolean"}})
begin_tool = {
"type": "function",
"function": {"name": "begin_write_file", "description": "Prepare a file", "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "description": {"type": "string"}, "append": {"type": "boolean"}}}},
}
token = provider._ACTIVE_WRITE_FILE_BRIDGE.set(provider._WriteFileBridgeStreamState(transport=transport))
try:
payload = model._get_request_payload([SystemMessage(content="SOUL 要求"), HumanMessage(content="写文件")], tools=[tool, begin_tool])
finally:
provider._ACTIVE_WRITE_FILE_BRIDGE.reset(token)
system = payload["messages"][0]["content"]
instruction = {
"text_bridge": provider._TEXT_BRIDGE_SELECTION_INSTRUCTION,
"buffered_gateway": provider._BUFFERED_GATEWAY_SELECTION_INSTRUCTION,
"artifact_renderer": provider._ARTIFACT_RENDERER_SELECTION_INSTRUCTION,
}[transport]
assert instruction in system
assert system.startswith("SOUL 要求")
assert system.endswith("\n\n请直接回答问题")
assert system.count("请直接回答问题") == 1