Simplify client mode to single FastGPT app

This commit is contained in:
Xin Wang
2026-07-28 10:38:01 +08:00
parent 3e9385b0ca
commit be6af046ca
7 changed files with 55 additions and 160 deletions

View File

@@ -3,14 +3,8 @@ SECRET_KEY=your_secret_key
DEBUG=True DEBUG=True
ANALYSIS_SERVICE_URL=http://127.0.0.1:3030 ANALYSIS_SERVICE_URL=http://127.0.0.1:3030
ANALYSIS_AUTH_TOKEN=replace-with-direct-fastgpt-api-key ANALYSIS_AUTH_TOKEN=replace-with-fastgpt-api-key
APP_ID=replace-with-direct-fastgpt-app-id APP_ID=replace-with-fastgpt-app-id
# Browser Addon FastGPT application. It shares ANALYSIS_SERVICE_URL unless
# FASTGPT_BROWSER_ADDON_BASE_URL is set.
FASTGPT_BROWSER_ADDON_API_KEY=replace-with-browser-addon-fastgpt-api-key
FASTGPT_BROWSER_ADDON_APP_ID=replace-with-browser-addon-fastgpt-app-id
# FASTGPT_BROWSER_ADDON_BASE_URL=http://127.0.0.1:3030
# Voice demo (Pipecat /ws-product). Relative to project root, or an absolute path. # Voice demo (Pipecat /ws-product). Relative to project root, or an absolute path.
VOICE_CONFIG=config/voice.json VOICE_CONFIG=config/voice.json

View File

@@ -1,11 +1,12 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from ..schemas.models import ProcessRequest_chat, ProcessResponse_chat, ProcessRequest_get, ProcessResponse_get, ProcessRequest_set, ProcessResponse_set, ProcessResponse_delete_session, ProcessRequest_delete_session from ..schemas.models import ProcessRequest_chat, ProcessResponse_chat, ProcessRequest_get, ProcessResponse_get, ProcessRequest_set, ProcessResponse_set, ProcessResponse_delete_session, ProcessRequest_delete_session
from fastgpt_client import AsyncChatClient, aiter_stream_events from fastgpt_client import AsyncChatClient, aiter_stream_events
from fastgpt_client.exceptions import ( from fastgpt_client.exceptions import (
APIError, AuthenticationError, RateLimitError, ValidationError APIError, AuthenticationError, RateLimitError, ValidationError
) )
from ..core.fastgpt_client import get_fastgpt_profile from ..core.fastgpt_client import get_fastgpt_client
from ..core.config import Config
from ..utils.text_chunker import SentenceTextChunker, SentenceTextChunkerConfig from ..utils.text_chunker import SentenceTextChunker, SentenceTextChunkerConfig
from loguru import logger from loguru import logger
import json import json
@@ -113,14 +114,13 @@ def format_set_info_input(payload: dict, include_input_info: bool) -> str:
async def delete_last_two_chat_records( async def delete_last_two_chat_records(
client: AsyncChatClient, client: AsyncChatClient,
app_id: str,
session_id: str, session_id: str,
) -> None: ) -> None:
"""Delete the last two chat records.""" """Delete the last two chat records."""
try: try:
# Get chat records using SDK # Get chat records using SDK
response = await client.get_chat_records( response = await client.get_chat_records(
appId=app_id, appId=Config.FASTGPT_APP_ID,
chatId=session_id, chatId=session_id,
offset=0, offset=0,
pageSize=10 pageSize=10
@@ -139,7 +139,7 @@ async def delete_last_two_chat_records(
# Delete records using SDK # Delete records using SDK
for content_id in last_two_data_ids: for content_id in last_two_data_ids:
delete_response = await client.delete_chat_record( delete_response = await client.delete_chat_record(
appId=app_id, appId=Config.FASTGPT_APP_ID,
chatId=session_id, chatId=session_id,
contentId=content_id contentId=content_id
) )
@@ -163,15 +163,17 @@ def create_sse_event(event: str, data: dict) -> str:
async def chat( async def chat(
request: ProcessRequest_chat, request: ProcessRequest_chat,
stream: bool = False, stream: bool = False,
client: AsyncChatClient = Depends(get_fastgpt_client),
): ):
"""Handle chat completion request.""" """Handle chat completion request."""
json_data = request.model_dump() json_data = request.model_dump()
client_mode = json_data['clientMode'] client_mode = json_data['clientMode']
profile = get_fastgpt_profile(client_mode)
client = profile.client
need_form_update = json_data.get('needFormUpdate', False) need_form_update = json_data.get('needFormUpdate', False)
use_text_chunk = json_data.get('useTextChunk', False) use_text_chunk = json_data.get('useTextChunk', False)
chat_variables = {'needFormUpdate': need_form_update} chat_variables = {
'needFormUpdate': need_form_update,
'clientMode': client_mode,
}
request_started_at = time.perf_counter() request_started_at = time.perf_counter()
logger.info( logger.info(
"Chat request received " "Chat request received "
@@ -566,11 +568,10 @@ async def chat(
@router.post("/set_info", response_model=ProcessResponse_set) @router.post("/set_info", response_model=ProcessResponse_set)
async def set_info( async def set_info(
request: ProcessRequest_set, request: ProcessRequest_set,
client: AsyncChatClient = Depends(get_fastgpt_client),
): ):
"""Set information in chat state.""" """Set information in chat state."""
json_data = request.model_dump() json_data = request.model_dump()
profile = get_fastgpt_profile(json_data['clientMode'])
client = profile.client
set_info_payload = {'key': json_data['key'], 'value': json_data['value']} set_info_payload = {'key': json_data['key'], 'value': json_data['value']}
set_info_input = format_set_info_input( set_info_input = format_set_info_input(
set_info_payload, set_info_payload,
@@ -605,7 +606,6 @@ async def set_info(
try: try:
await delete_last_two_chat_records( await delete_last_two_chat_records(
client, client,
profile.app_id,
json_data['sessionId'], json_data['sessionId'],
) )
except Exception as e: except Exception as e:
@@ -638,7 +638,6 @@ async def set_info(
# Delete records again after update # Delete records again after update
await delete_last_two_chat_records( await delete_last_two_chat_records(
client, client,
profile.app_id,
json_data['sessionId'], json_data['sessionId'],
) )
@@ -661,11 +660,10 @@ async def set_info(
@router.post("/get_info", response_model=ProcessResponse_get) @router.post("/get_info", response_model=ProcessResponse_get)
async def get_info( async def get_info(
request: ProcessRequest_get, request: ProcessRequest_get,
client: AsyncChatClient = Depends(get_fastgpt_client),
): ):
"""Get information from chat state.""" """Get information from chat state."""
json_data = request.model_dump() json_data = request.model_dump()
profile = get_fastgpt_profile(json_data['clientMode'])
client = profile.client
get_info_payload = {'key': json_data['key']} get_info_payload = {'key': json_data['key']}
get_info_input = format_set_info_input( get_info_input = format_set_info_input(
get_info_payload, get_info_payload,
@@ -701,7 +699,6 @@ async def get_info(
try: try:
await delete_last_two_chat_records( await delete_last_two_chat_records(
client, client,
profile.app_id,
json_data['sessionId'], json_data['sessionId'],
) )
except Exception as e: except Exception as e:
@@ -768,11 +765,10 @@ async def get_info(
@router.delete("/delete_session", response_model=ProcessResponse_delete_session) @router.delete("/delete_session", response_model=ProcessResponse_delete_session)
async def delete_session( async def delete_session(
request: ProcessRequest_delete_session, request: ProcessRequest_delete_session,
client: AsyncChatClient = Depends(get_fastgpt_client),
): ):
"""Delete a chat session.""" """Delete a chat session."""
json_data = request.model_dump() json_data = request.model_dump()
profile = get_fastgpt_profile(json_data['clientMode'])
client = profile.client
chat_id = json_data.get('sessionId') chat_id = json_data.get('sessionId')
if not chat_id: if not chat_id:
@@ -786,7 +782,7 @@ async def delete_session(
try: try:
# Use SDK's delete_chat_history # Use SDK's delete_chat_history
response = await client.delete_chat_history( response = await client.delete_chat_history(
appId=profile.app_id, appId=Config.FASTGPT_APP_ID,
chatId=chat_id chatId=chat_id
) )
response.raise_for_status() response.raise_for_status()

View File

@@ -10,42 +10,18 @@ class Config:
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db")
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "t") DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "t")
# FastGPT Configuration. Direct mode falls back to the legacy environment # FastGPT Configuration
# variable names so existing deployments keep working unchanged. FASTGPT_API_KEY = os.getenv("ANALYSIS_AUTH_TOKEN")
FASTGPT_DIRECT_API_KEY = ( FASTGPT_BASE_URL = os.getenv("ANALYSIS_SERVICE_URL")
os.getenv("FASTGPT_DIRECT_API_KEY") FASTGPT_APP_ID = os.getenv("APP_ID")
or os.getenv("ANALYSIS_AUTH_TOKEN")
)
FASTGPT_DIRECT_BASE_URL = (
os.getenv("FASTGPT_DIRECT_BASE_URL")
or os.getenv("ANALYSIS_SERVICE_URL")
)
FASTGPT_DIRECT_APP_ID = (
os.getenv("FASTGPT_DIRECT_APP_ID")
or os.getenv("APP_ID")
)
FASTGPT_BROWSER_ADDON_API_KEY = os.getenv("FASTGPT_BROWSER_ADDON_API_KEY")
FASTGPT_BROWSER_ADDON_BASE_URL = (
os.getenv("FASTGPT_BROWSER_ADDON_BASE_URL")
or FASTGPT_DIRECT_BASE_URL
)
FASTGPT_BROWSER_ADDON_APP_ID = os.getenv("FASTGPT_BROWSER_ADDON_APP_ID")
# Backward-compatible aliases for code outside the mode-aware API layer.
FASTGPT_API_KEY = FASTGPT_DIRECT_API_KEY
FASTGPT_BASE_URL = FASTGPT_DIRECT_BASE_URL
FASTGPT_APP_ID = FASTGPT_DIRECT_APP_ID
@classmethod @classmethod
def validate(cls): def validate(cls):
"""Validate required configuration.""" """Validate required configuration."""
required = [ required = [
("FASTGPT_DIRECT_BASE_URL (or ANALYSIS_SERVICE_URL)", cls.FASTGPT_DIRECT_BASE_URL), ("FASTGPT_BASE_URL (ANALYSIS_SERVICE_URL)", cls.FASTGPT_BASE_URL),
("FASTGPT_DIRECT_API_KEY (or ANALYSIS_AUTH_TOKEN)", cls.FASTGPT_DIRECT_API_KEY), ("FASTGPT_API_KEY (ANALYSIS_AUTH_TOKEN)", cls.FASTGPT_API_KEY),
("FASTGPT_DIRECT_APP_ID (or APP_ID)", cls.FASTGPT_DIRECT_APP_ID), ("FASTGPT_APP_ID (APP_ID)", cls.FASTGPT_APP_ID),
("FASTGPT_BROWSER_ADDON_API_KEY", cls.FASTGPT_BROWSER_ADDON_API_KEY),
("FASTGPT_BROWSER_ADDON_APP_ID", cls.FASTGPT_BROWSER_ADDON_APP_ID),
] ]
missing = [name for name, value in required if not value] missing = [name for name, value in required if not value]
if missing: if missing:

View File

@@ -1,76 +1,38 @@
"""FastGPT client dependency injection.""" """FastGPT client dependency injection."""
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass
from fastapi import FastAPI from fastapi import FastAPI
from fastgpt_client import AsyncChatClient from fastgpt_client import AsyncChatClient
from .config import Config from .config import Config
@dataclass(frozen=True) _fastgpt_client: AsyncChatClient | None = None
class FastGPTProfile:
"""FastGPT client and app metadata for one client mode."""
client: AsyncChatClient
app_id: str
_fastgpt_profiles: dict[str, FastGPTProfile] = {}
def _profile_settings() -> dict[str, dict[str, str | None]]:
return {
"direct": {
"api_key": Config.FASTGPT_DIRECT_API_KEY,
"base_url": Config.FASTGPT_DIRECT_BASE_URL,
"app_id": Config.FASTGPT_DIRECT_APP_ID,
},
"browser_addon": {
"api_key": Config.FASTGPT_BROWSER_ADDON_API_KEY,
"base_url": Config.FASTGPT_BROWSER_ADDON_BASE_URL,
"app_id": Config.FASTGPT_BROWSER_ADDON_APP_ID,
},
}
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Manage all mode-specific FastGPT clients.""" """Manage the FastGPT client lifecycle."""
global _fastgpt_client
Config.validate() Config.validate()
async with AsyncExitStack() as stack: _fastgpt_client = AsyncChatClient(
for client_mode, settings in _profile_settings().items(): api_key=Config.FASTGPT_API_KEY,
client = AsyncChatClient( base_url=Config.FASTGPT_BASE_URL,
api_key=settings["api_key"], timeout=60.0,
base_url=settings["base_url"], max_retries=3,
timeout=60.0, retry_delay=1.0,
max_retries=3, enable_logging=Config.DEBUG,
retry_delay=1.0, )
enable_logging=Config.DEBUG, await _fastgpt_client.__aenter__()
)
await stack.enter_async_context(client)
_fastgpt_profiles[client_mode] = FastGPTProfile(
client=client,
app_id=settings["app_id"],
)
try:
yield
finally:
_fastgpt_profiles.clear()
def get_fastgpt_profile(client_mode: str) -> FastGPTProfile:
"""Get the initialized FastGPT profile for a client mode."""
try: try:
return _fastgpt_profiles[client_mode] yield
except KeyError as exc: finally:
if client_mode not in _profile_settings(): await _fastgpt_client.__aexit__(None, None, None)
raise ValueError(f"Unsupported clientMode: {client_mode}") from exc _fastgpt_client = None
raise RuntimeError(
f"FastGPT profile is not initialized for clientMode={client_mode}"
) from exc
def get_fastgpt_client(client_mode: str = "direct") -> AsyncChatClient: def get_fastgpt_client() -> AsyncChatClient:
"""Get a FastGPT client, defaulting to the legacy direct mode.""" """Get the initialized FastGPT client."""
return get_fastgpt_profile(client_mode).client if _fastgpt_client is None:
raise RuntimeError("FastGPT client not initialized")
return _fastgpt_client

View File

@@ -25,7 +25,6 @@ class ProcessRequest_get(BaseModel):
sessionId: str = Field(..., max_length=64) sessionId: str = Field(..., max_length=64)
timeStamp: str = Field(..., max_length=32) timeStamp: str = Field(..., max_length=32)
key: str = Field(...) key: str = Field(...)
clientMode: ClientMode = "direct"
includeInputInfo: bool = False includeInputInfo: bool = False
class ProcessResponse_get(BaseModel): class ProcessResponse_get(BaseModel):
@@ -40,7 +39,6 @@ class ProcessRequest_set(BaseModel):
timeStamp: str = Field(..., max_length=32) timeStamp: str = Field(..., max_length=32)
key: str = Field(...) key: str = Field(...)
value: str = Field(...) value: str = Field(...)
clientMode: ClientMode = "direct"
includeInputInfo: bool = False includeInputInfo: bool = False
class ProcessResponse_set(BaseModel): class ProcessResponse_set(BaseModel):
@@ -52,7 +50,6 @@ class ProcessResponse_set(BaseModel):
class ProcessRequest_delete_session(BaseModel): class ProcessRequest_delete_session(BaseModel):
sessionId: str = Field(..., max_length=64) sessionId: str = Field(..., max_length=64)
timeStamp: str = Field(..., max_length=32) timeStamp: str = Field(..., max_length=32)
clientMode: ClientMode = "direct"
class ProcessResponse_delete_session(BaseModel): class ProcessResponse_delete_session(BaseModel):
sessionId: str = Field(..., max_length=64) sessionId: str = Field(..., max_length=64)

View File

@@ -18,8 +18,7 @@ content-type: application/json
{ {
"sessionId": "{{sessionId}}", "sessionId": "{{sessionId}}",
"timeStamp": "{{$timestamp}}", "timeStamp": "{{$timestamp}}"
"clientMode": "{{clientMode}}"
} }
HTTP/1.1 200 - OK HTTP/1.1 200 - OK
@@ -54,8 +53,7 @@ content-type: application/json
{ {
"sessionId": "{{sessionId}}", "sessionId": "{{sessionId}}",
"timeStamp": "{{timeStamp}}", "timeStamp": "{{timeStamp}}",
"key": "hphm1", "key": "hphm1"
"clientMode": "{{clientMode}}"
} }
HTTP/1.1 200 - OK HTTP/1.1 200 - OK
@@ -72,8 +70,7 @@ content-type: application/json
"sessionId": "{{sessionId}}", "sessionId": "{{sessionId}}",
"timeStamp": "{{timeStamp}}", "timeStamp": "{{timeStamp}}",
"key": "hphm1", "key": "hphm1",
"value": "沪A8939", "value": "沪A8939"
"clientMode": "{{clientMode}}"
} }
HTTP/1.1 200 - OK HTTP/1.1 200 - OK

View File

@@ -2,42 +2,15 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from src.api.endpoints import external_stage_code from src.api.endpoints import external_stage_code
from src.schemas.models import ( from src.schemas.models import ProcessRequest_chat
ProcessRequest_chat,
ProcessRequest_delete_session,
ProcessRequest_get,
ProcessRequest_set,
)
@pytest.mark.parametrize( def test_client_mode_defaults_to_direct():
("request_model", "payload"), request = ProcessRequest_chat(
[ sessionId="session-1",
( timeStamp="1",
ProcessRequest_chat, text="你好",
{"sessionId": "session-1", "timeStamp": "1", "text": "你好"}, )
),
(
ProcessRequest_set,
{
"sessionId": "session-1",
"timeStamp": "1",
"key": "name",
"value": "张三",
},
),
(
ProcessRequest_get,
{"sessionId": "session-1", "timeStamp": "1", "key": "name"},
),
(
ProcessRequest_delete_session,
{"sessionId": "session-1", "timeStamp": "1"},
),
],
)
def test_client_mode_defaults_to_direct(request_model, payload):
request = request_model(**payload)
assert request.clientMode == "direct" assert request.clientMode == "direct"