Add fastgpt as seperate assistant mode

This commit is contained in:
Xin Wang
2026-03-11 08:37:34 +08:00
parent 13684d498b
commit f3612a710d
26 changed files with 2333 additions and 210 deletions

View File

@@ -283,6 +283,30 @@ def test_translate_agent_schema_maps_volcengine_fields():
}
def test_translate_agent_schema_maps_llm_app_id():
payload = {
"agent": {
"llm": {
"provider": "fastgpt",
"model": "fastgpt",
"api_key": "llm-key",
"api_url": "https://cloud.fastgpt.cn/api",
"app_id": "app-fastgpt-123",
},
}
}
translated = LocalYamlAssistantConfigAdapter._translate_agent_schema("assistant_demo", payload)
assert translated is not None
assert translated["services"]["llm"] == {
"provider": "fastgpt",
"model": "fastgpt",
"apiKey": "llm-key",
"baseUrl": "https://cloud.fastgpt.cn/api",
"appId": "app-fastgpt-123",
}
@pytest.mark.asyncio
async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(monkeypatch, tmp_path):
class _FailIfCalledClientSession:

View File

@@ -0,0 +1,411 @@
import json
from types import SimpleNamespace
from typing import Any, Dict, List
import pytest
from providers.common.base import LLMMessage
from providers.llm.fastgpt import FastGPTLLMService
class _FakeResponse:
def __init__(self, events: List[Any]):
self.events = events
self.closed = False
async def close(self) -> None:
self.closed = True
class _FakeJSONResponse:
def __init__(self, payload: Dict[str, Any], status_code: int = 200):
self._payload = payload
self.status_code = status_code
def json(self) -> Dict[str, Any]:
return dict(self._payload)
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
class _FakeAsyncStreamResponse(_FakeResponse):
def __init__(self, events: List[Any]):
super().__init__(events)
self.aclosed = False
def close(self) -> None:
raise AssertionError("sync close should not be used for async stream responses")
async def aclose(self) -> None:
self.aclosed = True
class _FakeAsyncChatClient:
responses: List[_FakeResponse] = []
init_payload: Dict[str, Any] | None = None
def __init__(self, api_key: str, base_url: str):
self.api_key = api_key
self.base_url = base_url
self.requests: List[Dict[str, Any]] = []
self.init_requests: List[Dict[str, Any]] = []
async def create_chat_completion(self, **kwargs):
self.requests.append(dict(kwargs))
if not self.responses:
raise AssertionError("No fake FastGPT response queued")
return self.responses.pop(0)
async def get_chat_init(self, **kwargs):
self.init_requests.append(dict(kwargs))
return _FakeJSONResponse(
self.init_payload or {"data": {"app": {"chatConfig": {"welcomeText": ""}}}},
)
async def close(self) -> None:
return None
async def _fake_aiter_stream_events(response: _FakeResponse):
for event in response.events:
yield event
@pytest.mark.asyncio
async def test_fastgpt_provider_streams_text_from_data_event(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="data",
data={"choices": [{"delta": {"content": "Hello from FastGPT."}}]},
),
SimpleNamespace(kind="done", data={}),
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
assert [event.type for event in events] == ["text_delta", "done"]
assert events[0].text == "Hello from FastGPT."
assert service.client.requests[0]["messages"] == [{"role": "user", "content": "Hi"}]
assert service.client.requests[0]["chatId"] == service._state.chat_id
@pytest.mark.asyncio
async def test_fastgpt_provider_streams_text_from_answer_delta_event(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="answer",
data={"choices": [{"delta": {"content": "Hello from answer delta."}}]},
),
SimpleNamespace(kind="done", data={}),
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
assert [event.type for event in events] == ["text_delta", "done"]
assert events[0].text == "Hello from answer delta."
@pytest.mark.asyncio
async def test_fastgpt_provider_uses_async_close_for_stream_responses(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
response = _FakeAsyncStreamResponse(
[
SimpleNamespace(
kind="data",
data={"choices": [{"delta": {"content": "Hello from FastGPT."}}]},
),
SimpleNamespace(kind="done", data={}),
]
)
_FakeAsyncChatClient.responses = [response]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
assert [event.type for event in events] == ["text_delta", "done"]
assert response.aclosed is True
@pytest.mark.asyncio
async def test_fastgpt_provider_loads_initial_greeting_from_chat_init(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.init_payload = {
"data": {
"app": {
"chatConfig": {
"welcomeText": "Hello from FastGPT init.",
}
}
}
}
service = FastGPTLLMService(
api_key="key",
base_url="https://fastgpt.example",
app_id="app-123",
)
await service.connect()
greeting = await service.get_initial_greeting()
assert greeting == "Hello from FastGPT init."
assert service.client.init_requests[0] == {
"appId": "app-123",
"chatId": service._state.chat_id,
}
@pytest.mark.asyncio
async def test_fastgpt_provider_maps_interactive_event_to_client_tool(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="interactive",
data={
"type": "userSelect",
"title": "Choose a plan",
"params": {
"description": "Pick the best plan for your team.",
"userSelectOptions": [
{"id": "basic", "label": "Basic", "value": "basic", "desc": "Starter tier"},
{"id": "pro", "label": "Pro", "value": "pro", "description": "Advanced tier"},
]
},
},
)
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
assert len(events) == 1
assert events[0].type == "tool_call"
tool_call = events[0].tool_call
assert tool_call["executor"] == "client"
assert tool_call["wait_for_response"] is True
assert tool_call["timeout_ms"] == 300000
assert tool_call["function"]["name"] == "fastgpt.interactive"
arguments = json.loads(tool_call["function"]["arguments"])
assert arguments["provider"] == "fastgpt"
assert arguments["version"] == "fastgpt_interactive_v1"
assert arguments["interaction"]["type"] == "userSelect"
assert arguments["interaction"]["description"] == "Pick the best plan for your team."
assert arguments["interaction"]["options"][0]["description"] == "Starter tier"
assert arguments["interaction"]["options"][1]["value"] == "pro"
assert arguments["interaction"]["options"][1]["description"] == "Advanced tier"
assert arguments["context"]["chat_id"] == service._state.chat_id
assert service._state.pending_interaction is not None
@pytest.mark.asyncio
async def test_fastgpt_provider_unwraps_nested_tool_children_interactive(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="interactive",
data={
"interactive": {
"type": "toolChildrenInteractive",
"params": {
"childrenResponse": {
"type": "userSelect",
"params": {
"description": "Please choose a workflow branch.",
"userSelectOptions": [
{"value": "A", "description": "Branch A"},
{"value": "B", "description": "Branch B"},
],
},
}
},
}
},
)
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
assert len(events) == 1
arguments = json.loads(events[0].tool_call["function"]["arguments"])
assert arguments["interaction"]["type"] == "userSelect"
assert arguments["interaction"]["description"] == "Please choose a workflow branch."
assert arguments["interaction"]["options"][0]["description"] == "Branch A"
@pytest.mark.asyncio
async def test_fastgpt_provider_uses_opener_for_interactive_prompt_when_prompt_missing(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="interactive",
data={
"type": "userSelect",
"opener": "请确认您是否满意本次服务。",
"params": {
"userSelectOptions": [
{"value": ""},
{"value": ""},
]
},
},
)
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
assert len(events) == 1
tool_call = events[0].tool_call
arguments = json.loads(tool_call["function"]["arguments"])
assert tool_call["display_name"] == "请确认您是否满意本次服务。"
assert arguments["interaction"]["prompt"] == "请确认您是否满意本次服务。"
@pytest.mark.asyncio
async def test_fastgpt_provider_resumes_same_chat_after_client_result(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="interactive",
data={
"type": "userSelect",
"params": {"userSelectOptions": [{"label": "Pro", "value": "pro"}]},
},
)
]
),
_FakeResponse(
[
SimpleNamespace(kind="answer", data={"text": "Resumed answer."}),
SimpleNamespace(kind="done", data={}),
]
),
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
initial_events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
call_id = initial_events[0].tool_call["id"]
resumed_events = [
event
async for event in service.resume_after_client_tool_result(
call_id,
{
"tool_call_id": call_id,
"name": "fastgpt.interactive",
"output": {
"action": "submit",
"result": {"type": "userSelect", "selected": "pro"},
},
"status": {"code": 200, "message": "ok"},
},
)
]
assert [event.type for event in resumed_events] == ["text_delta", "done"]
assert resumed_events[0].text == "Resumed answer."
assert service.client.requests[1]["chatId"] == service.client.requests[0]["chatId"]
assert service.client.requests[1]["messages"] == [{"role": "user", "content": "pro"}]
assert service._state.pending_interaction is None
@pytest.mark.asyncio
async def test_fastgpt_provider_cancel_result_clears_pending_interaction(monkeypatch):
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
_FakeAsyncChatClient.responses = [
_FakeResponse(
[
SimpleNamespace(
kind="interactive",
data={
"type": "userInput",
"params": {"inputForm": [{"name": "name", "label": "Name"}]},
},
)
]
)
]
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
await service.connect()
initial_events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
call_id = initial_events[0].tool_call["id"]
resumed_events = [
event
async for event in service.resume_after_client_tool_result(
call_id,
{
"tool_call_id": call_id,
"name": "fastgpt.interactive",
"output": {"action": "cancel", "result": {}},
"status": {"code": 499, "message": "user_cancelled"},
},
)
]
assert [event.type for event in resumed_events] == ["done"]
assert service._state.pending_interaction is None

View File

@@ -109,6 +109,22 @@ class _CaptureGenerateLLM:
yield LLMStreamEvent(type="done")
class _InitGreetingLLM:
def __init__(self, greeting: str):
self.greeting = greeting
self.init_calls = 0
async def generate(self, _messages, temperature=0.7, max_tokens=None):
return ""
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
yield LLMStreamEvent(type="done")
async def get_initial_greeting(self):
self.init_calls += 1
return self.greeting
def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD)
monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor)
@@ -306,6 +322,21 @@ async def test_generated_opener_uses_tool_capable_turn_when_tools_available(monk
assert called.get("user_text") == ""
@pytest.mark.asyncio
async def test_provider_initial_greeting_takes_precedence_over_local_opener(monkeypatch):
llm = _InitGreetingLLM("FastGPT init greeting")
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
pipeline.conversation.greeting = "local fallback greeting"
await pipeline.emit_initial_greeting()
finals = [event for event in events if event.get("type") == "assistant.response.final"]
assert finals
assert finals[-1]["text"] == "FastGPT init greeting"
assert llm.init_calls == 1
@pytest.mark.asyncio
async def test_manual_opener_tool_calls_emit_assistant_tool_call(monkeypatch):
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
@@ -736,3 +767,139 @@ async def test_eou_early_return_clears_stale_asr_capture(monkeypatch):
assert pipeline._asr_capture_active is False
assert pipeline._asr_capture_started_ms == 0.0
assert pipeline._pending_speech_audio == b""
class _FakeResumableLLM:
def __init__(self, *, timeout_ms: int = 300000):
self.timeout_ms = timeout_ms
self.generate_stream_calls = 0
self.resumed_results: List[Dict[str, Any]] = []
async def generate(self, _messages, temperature=0.7, max_tokens=None):
return ""
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
self.generate_stream_calls += 1
yield LLMStreamEvent(
type="tool_call",
tool_call={
"id": "call_fastgpt_1",
"executor": "client",
"wait_for_response": True,
"timeout_ms": self.timeout_ms,
"display_name": "Choose a plan",
"type": "function",
"function": {
"name": "fastgpt.interactive",
"arguments": json.dumps(
{
"provider": "fastgpt",
"version": "fastgpt_interactive_v1",
"interaction": {
"type": "userSelect",
"title": "Choose a plan",
"options": [
{"id": "basic", "label": "Basic", "value": "basic"},
{"id": "pro", "label": "Pro", "value": "pro"},
],
"form": [],
},
"context": {"chat_id": "fastgpt_chat_1"},
},
ensure_ascii=False,
),
},
},
)
yield LLMStreamEvent(type="done")
def handles_client_tool(self, tool_name: str) -> bool:
return tool_name == "fastgpt.interactive"
async def resume_after_client_tool_result(self, tool_call_id: str, result: Dict[str, Any]):
self.resumed_results.append({"tool_call_id": tool_call_id, "result": dict(result)})
yield LLMStreamEvent(type="text_delta", text="provider resumed answer.")
yield LLMStreamEvent(type="done")
def _build_pipeline_with_custom_llm(monkeypatch, llm_service) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD)
monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor)
monkeypatch.setattr("runtime.pipeline.duplex.EouDetector", _DummyEouDetector)
pipeline = DuplexPipeline(
transport=_FakeTransport(),
session_id="s_fastgpt",
llm_service=llm_service,
tts_service=_FakeTTS(),
asr_service=_FakeASR(),
)
events: List[Dict[str, Any]] = []
async def _capture_event(event: Dict[str, Any], priority: int = 20):
events.append(event)
async def _noop_speak(_text: str, *args, **kwargs):
return None
monkeypatch.setattr(pipeline, "_send_event", _capture_event)
monkeypatch.setattr(pipeline, "_speak_sentence", _noop_speak)
return pipeline, events
@pytest.mark.asyncio
async def test_fastgpt_provider_managed_tool_resumes_provider_stream(monkeypatch):
llm = _FakeResumableLLM(timeout_ms=300000)
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
task = asyncio.create_task(pipeline._handle_turn("start fastgpt"))
for _ in range(200):
if any(event.get("type") == "assistant.tool_call" for event in events):
break
await asyncio.sleep(0.005)
tool_event = next(event for event in events if event.get("type") == "assistant.tool_call")
assert tool_event.get("executor") == "client"
assert tool_event.get("tool_name") == "fastgpt.interactive"
assert tool_event.get("timeout_ms") == 300000
assert tool_event.get("arguments", {}).get("context", {}).get("turn_id")
assert tool_event.get("arguments", {}).get("context", {}).get("response_id")
await pipeline.handle_tool_call_results(
[
{
"tool_call_id": "call_fastgpt_1",
"name": "fastgpt.interactive",
"output": {
"action": "submit",
"result": {"type": "userSelect", "selected": "pro"},
},
"status": {"code": 200, "message": "ok"},
}
]
)
await task
finals = [event for event in events if event.get("type") == "assistant.response.final"]
assert finals
assert "provider resumed answer" in finals[-1].get("text", "")
assert llm.generate_stream_calls == 1
assert len(llm.resumed_results) == 1
assert llm.resumed_results[0]["tool_call_id"] == "call_fastgpt_1"
@pytest.mark.asyncio
async def test_fastgpt_provider_managed_tool_timeout_stops_without_generic_tool_prompt(monkeypatch):
llm = _FakeResumableLLM(timeout_ms=10)
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
await pipeline._handle_turn("start fastgpt")
tool_results = [event for event in events if event.get("type") == "assistant.tool_result"]
assert tool_results
assert tool_results[-1].get("result", {}).get("status", {}).get("code") == 504
finals = [event for event in events if event.get("type") == "assistant.response.final"]
assert not finals
assert llm.generate_stream_calls == 1
assert llm.resumed_results == []