ver 1.0.2
This commit is contained in:
30
README.md
30
README.md
@@ -1,2 +1,30 @@
|
||||
# nekochatapi
|
||||
# > x <
|
||||
|
||||
乱写的,用于统一简化API交互,主要用于neko.ci的misskey翻译用
|
||||
|
||||
示例
|
||||
```
|
||||
rsesot@babydoll:~$ curl -N -X POST http://localhost:3060/api/mthreads/chat -H "Content-Type: application/json" -d '{
|
||||
"prompt": [
|
||||
"你说一句日语冷笑话"
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 1024
|
||||
}'
|
||||
event: complete
|
||||
data: ["ちょっと変わった日本の冷えたジョークです:\n\nなぜか冬に氷が暖かいと言いますか?\n\n氷は冬にしか作れないからです! 😄❄️\n\n(訳注: 冬にしか作れないから「暖かい」と冗談を言っています。)"]
|
||||
```
|
||||
```
|
||||
rsesot@babydoll:~$ curl -N -X POST http://localhost:3060/api/glm/chat -H "Content-Type: application/json" -d '{
|
||||
"prompt": [
|
||||
"你说一句日语冷笑话"
|
||||
],
|
||||
"temperature": 0.5,
|
||||
"top_p": 0.9,
|
||||
"max_tokens": 1024,
|
||||
"web_search": true
|
||||
}'
|
||||
event: complete
|
||||
data: ["日本語の冷笑话です:\n「猿が電車に乗ると、猿の毛が落ちて電車の座席に溜まります。すると、次の乗客が「猿の毛が落ちてる!」って言うと、猿が「いや、私の毛じゃないんだよ!」って言うけど、誰も信じてくれないんです。」(猿が電車に乗ると、猿の毛が落ちて電車の座席に溜まります。すると、次の乗客が「猿の毛が落ちてる!」って言うと、猿が「いや、私の毛じゃないんだよ!」って言うけど、誰も信じてくれないんです。)"]
|
||||
```
|
||||
|
||||
76
api/glm.py
Normal file
76
api/glm.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from typing import Any, Dict
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models import ChatRequest
|
||||
from utils import convert_prompt_to_messages, standard_sse_generator
|
||||
from config import GLM_API_KEY, GLM_UPSTREAM_URL
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("proxy")
|
||||
|
||||
|
||||
@router.post("/glm/chat")
|
||||
async def proxy_glm_chat(request: ChatRequest):
|
||||
|
||||
request_id = f"glm-{uuid.uuid4()}"
|
||||
|
||||
if not GLM_API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="GLM API key is not configured.",
|
||||
)
|
||||
|
||||
client_request_body = request.model_dump()
|
||||
logger.debug(
|
||||
f"[{request_id}] === GLM New Request ===\n"
|
||||
f"[{request_id}] Client Request Body:\n"
|
||||
f"{json.dumps(client_request_body, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
messages = convert_prompt_to_messages(request.prompt)
|
||||
logger.debug(
|
||||
f"[{request_id}] Agent Transformed Messages:\n"
|
||||
f"{json.dumps(messages, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# 基础的请求体
|
||||
payload: Dict[str, Any] = {
|
||||
"model": "glm-4-flash",
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
}
|
||||
|
||||
# 如果请求中指定了 max_tokens ,则添加到 payload
|
||||
if request.max_tokens:
|
||||
payload["max_tokens"] = request.max_tokens
|
||||
|
||||
# 处理 GLM web_search 功能
|
||||
if request.web_search:
|
||||
tool = {"type": "web_search", "web_search": {}}
|
||||
if request.web_search is True:
|
||||
# 如果是简单的 true,则启用默认的网络搜索
|
||||
tool["web_search"] = {"enable": True}
|
||||
elif isinstance(request.web_search, dict):
|
||||
# 如果是字典,则使用用户提供的配置
|
||||
tool["web_search"] = request.web_search
|
||||
# 确保 enable 标志为 True
|
||||
tool["web_search"]["enable"] = True
|
||||
payload["tools"] = [tool]
|
||||
|
||||
logger.debug(
|
||||
f"[{request_id}] Upstream Payload:\n"
|
||||
f"{json.dumps(payload, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
headers = {"Authorization": f"Bearer {GLM_API_KEY}"}
|
||||
return StreamingResponse(
|
||||
standard_sse_generator(GLM_UPSTREAM_URL, payload, headers, "GLM", request_id),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
122
api/mthreads.py
Normal file
122
api/mthreads.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import MTHREADS_UPSTREAM_URL
|
||||
from models import ChatRequest
|
||||
from utils import (
|
||||
UpstreamAPIError,
|
||||
sanitize_mthreads_response,
|
||||
handle_upstream_response,
|
||||
generate_error_sse,
|
||||
generate_network_error_sse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("proxy")
|
||||
|
||||
|
||||
@router.post("/mthreads/chat")
|
||||
async def proxy_mthreads_chat(request: ChatRequest):
|
||||
|
||||
request_id = f"mthreads-{uuid.uuid4()}"
|
||||
client_request_body = request.model_dump()
|
||||
|
||||
logger.debug(
|
||||
f"[{request_id}] === MThreads New Request ===\n"
|
||||
f"[{request_id}] Client Request Body:\n"
|
||||
f"{json.dumps(client_request_body, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
prompt_str = "\n".join(request.prompt)
|
||||
mthreads_payload = {
|
||||
"data": [prompt_str, request.temperature, request.top_p, request.max_tokens]
|
||||
}
|
||||
logger.debug(
|
||||
f"[{request_id}] Agent Transformed Payload (to be sent to upstream):\n"
|
||||
f"{json.dumps(mthreads_payload, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# --- 第一步: POST 请求获取 event_id ---
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
MTHREADS_UPSTREAM_URL,
|
||||
json=mthreads_payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
await handle_upstream_response(response)
|
||||
response_json = response.json()
|
||||
logger.debug(
|
||||
f"[{request_id}] Upstream Initial Response:\n"
|
||||
f"{json.dumps(response_json, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
event_id = response_json.get("event_id")
|
||||
if not event_id:
|
||||
raise UpstreamAPIError(
|
||||
status_code=502,
|
||||
message="Failed to get event_id from MThreads response.",
|
||||
details=response_json,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return StreamingResponse(
|
||||
generate_network_error_sse(e, request_id),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
except (UpstreamAPIError, json.JSONDecodeError) as e:
|
||||
if isinstance(e, json.JSONDecodeError):
|
||||
e = UpstreamAPIError(status_code=502, message="MThreads returned a non-JSON response.")
|
||||
return StreamingResponse(generate_error_sse(e, request_id), media_type="text/event-stream")
|
||||
|
||||
async def stream_generator():
|
||||
"""
|
||||
第一次请求获取 event_id 后,轮询流式数据
|
||||
"""
|
||||
# --- 第二步: GET 请求流式数据 ---
|
||||
stream_url = f"{MTHREADS_UPSTREAM_URL}/{event_id}"
|
||||
logger.debug(f"[{request_id}] Streaming from URL: {stream_url}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
async with client.stream("GET", stream_url) as response:
|
||||
await handle_upstream_response(response)
|
||||
line_iterator = response.aiter_lines()
|
||||
async for line in line_iterator:
|
||||
|
||||
if line.startswith("event: complete"):
|
||||
data_line = await line_iterator.__anext__()
|
||||
logger.debug(f"[{request_id}] Upstream Raw Data Line: {data_line}")
|
||||
raw_text = json.loads(data_line.split(":", 1)[1].strip())[0]
|
||||
logger.debug(f"[{request_id}] Upstream Parsed Raw Text: '{raw_text}'")
|
||||
|
||||
# 清理 MThreads 返回的非标准文本
|
||||
final_text = sanitize_mthreads_response(raw_text)
|
||||
logger.debug(f"[{request_id}] Agent Cleaned Text: '{final_text}'")
|
||||
|
||||
formatted_data = json.dumps([final_text], ensure_ascii=False)
|
||||
chunk_for_client_event = f"event: complete\n"
|
||||
chunk_for_client_data = f"data: {formatted_data}\n\n"
|
||||
logger.debug(
|
||||
f"[{request_id}] Yielding to Client:\n{chunk_for_client_event}{chunk_for_client_data}"
|
||||
)
|
||||
yield chunk_for_client_event
|
||||
yield chunk_for_client_data
|
||||
# 收到 complete 事件后,认为流已结束
|
||||
break
|
||||
except httpx.RequestError as e:
|
||||
async for item in generate_network_error_sse(e, request_id):
|
||||
yield item
|
||||
except (UpstreamAPIError, StopAsyncIteration, json.JSONDecodeError) as e:
|
||||
if isinstance(e, json.JSONDecodeError):
|
||||
err = UpstreamAPIError(status_code=502, message="Failed to parse MThreads stream response.")
|
||||
elif isinstance(e, StopAsyncIteration):
|
||||
err = UpstreamAPIError(status_code=502, message="MThreads stream was interrupted.")
|
||||
else:
|
||||
err = e
|
||||
async for item in generate_error_sse(err, request_id):
|
||||
yield item
|
||||
|
||||
return StreamingResponse(stream_generator(), media_type="text/event-stream")
|
||||
68
api/qwen.py
Normal file
68
api/qwen.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from config import QWEN_API_KEY, QWEN_UPSTREAM_URL
|
||||
from models import ChatRequest
|
||||
from utils import convert_prompt_to_messages, standard_sse_generator
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("proxy")
|
||||
|
||||
|
||||
@router.post("/qwen/chat")
|
||||
async def proxy_qwen_chat(request: ChatRequest):
|
||||
|
||||
# 为每个请求生成一个唯一的 ID,方便在日志中追踪
|
||||
request_id = f"qwen-{uuid.uuid4()}"
|
||||
|
||||
# 检查 API Key 是否已在 config.py 中配置
|
||||
if not QWEN_API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Qwen API key is not configured.",
|
||||
)
|
||||
|
||||
# 记录从客户端收到的原始请求体
|
||||
client_request_body = request.model_dump()
|
||||
logger.debug(
|
||||
f"[{request_id}] === Qwen New Request ===\n"
|
||||
f"[{request_id}] Client Request Body:\n"
|
||||
f"{json.dumps(client_request_body, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# 将 prompt 格式转换为 API 所需的 messages 格式
|
||||
messages = convert_prompt_to_messages(request.prompt)
|
||||
logger.debug(
|
||||
f"[{request_id}] Agent Transformed Messages:\n"
|
||||
f"{json.dumps(messages, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# 构建发送到上游 API 的 payload
|
||||
payload = {
|
||||
"model": "qwen-plus", # 指定使用的模型
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
"max_tokens": request.max_tokens,
|
||||
}
|
||||
logger.debug(
|
||||
f"[{request_id}] Upstream Payload:\n"
|
||||
f"{json.dumps(payload, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# 请求头,包含认证信息
|
||||
headers = {"Authorization": f"Bearer {QWEN_API_KEY}"}
|
||||
|
||||
# 返回一个流式响应对象
|
||||
# standard_sse_generator 会处理与上游的通信和流式数据的转发
|
||||
return StreamingResponse(
|
||||
standard_sse_generator(
|
||||
QWEN_UPSTREAM_URL, payload, headers, "Qwen", request_id
|
||||
),
|
||||
media_type="text/event-stream", # 指定 SSE 的 MIME 类型
|
||||
)
|
||||
20
config.py
Normal file
20
config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# --- API Key Configuration ---
|
||||
# GLM API Key
|
||||
GLM_API_KEY = "xxxxxxx"
|
||||
# Qwen API Key
|
||||
QWEN_API_KEY = "xxxxxxx"
|
||||
|
||||
# --- Upstream Service URLs ---
|
||||
# MThreads Playground API
|
||||
MTHREADS_UPSTREAM_URL = "https://qwen2dot5-14b-instruct-1m.playground.mthreads.com/call/generate_response"
|
||||
# Qwen DashScope API
|
||||
QWEN_UPSTREAM_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
# GLM BigModel API
|
||||
GLM_UPSTREAM_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
|
||||
|
||||
# --- Default Listener Configuration ---
|
||||
DEFAULT_LISTEN_HOST = "0.0.0.0"
|
||||
DEFAULT_LISTEN_PORT = 3060
|
||||
|
||||
# --- Global Variables ---
|
||||
DEBUG = False
|
||||
96
main.py
Normal file
96
main.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
import config
|
||||
# 导入 API 路由模块
|
||||
from api import glm, mthreads, qwen
|
||||
from config import DEFAULT_LISTEN_HOST, DEFAULT_LISTEN_PORT
|
||||
|
||||
|
||||
def setup_logging(debug: bool):
|
||||
"""
|
||||
配置日志系统,设置 proxy 专用日志记录器,避免与 uvicorn 等第三方库的日志冲突
|
||||
"""
|
||||
# 将记录器设置为最低级别(DEBUG),以便将所有消息传递给其处理器
|
||||
app_logger = logging.getLogger("proxy")
|
||||
app_logger.setLevel(logging.DEBUG)
|
||||
app_logger.propagate = False # 防止日志消息传播到根记录器,避免重复记录
|
||||
|
||||
# 清除现有处理器,确保配置唯一性
|
||||
app_logger.handlers.clear()
|
||||
|
||||
# 定义所有日志共享的格式
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
# 在控制台显示 INFO 及以上级别的日志,保证控制台不会被 DEBUG 信息淹没
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
app_logger.addHandler(console_handler)
|
||||
|
||||
if debug:
|
||||
# 在调试模式下,将 DEBUG 及以上级别的日志写入文件
|
||||
file_handler = logging.FileHandler("debug.log", mode="w", encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
app_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
# 命令行参数解析
|
||||
parser = argparse.ArgumentParser(description="LLM API Proxy Server")
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
type=int,
|
||||
default=DEFAULT_LISTEN_PORT,
|
||||
help=f"Port to run the server on (default: {DEFAULT_LISTEN_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug mode, writing detailed logs to debug.log",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
listen_port = args.port
|
||||
config.DEBUG = args.debug
|
||||
|
||||
# 配置日志系统
|
||||
setup_logging(config.DEBUG)
|
||||
|
||||
# 获取配置好的专用日志记录器
|
||||
logger = logging.getLogger("proxy")
|
||||
|
||||
app = FastAPI(
|
||||
title="LLM Chat API Proxy",
|
||||
description="A proxy for LLM APIs, supporting GLM, Qwen, and MThreads Playground.",
|
||||
version="1.0.2",
|
||||
)
|
||||
|
||||
# 包含来自各个 API 模块的路由
|
||||
app.include_router(mthreads.router, prefix="/api")
|
||||
app.include_router(qwen.router, prefix="/api")
|
||||
app.include_router(glm.router, prefix="/api")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info(
|
||||
f"Starting LLM API Proxy v1.0.2 on http://{DEFAULT_LISTEN_HOST}:{listen_port}"
|
||||
)
|
||||
if config.DEBUG:
|
||||
logger.info("Debug mode is enabled. Logging to debug.log")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=DEFAULT_LISTEN_HOST,
|
||||
port=listen_port,
|
||||
reload=True,
|
||||
reload_delay=1.0,
|
||||
)
|
||||
25
models.py
Normal file
25
models.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
|
||||
prompt: List[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The user's prompt, where the first element is the system prompt.",
|
||||
)
|
||||
|
||||
temperature: float = 0.7
|
||||
|
||||
top_p: float = 0.9
|
||||
|
||||
max_tokens: int = 2048
|
||||
|
||||
# 是否启用网络搜索功能,可选项
|
||||
# 可以是一个布尔值或一个包含详细配置的字典
|
||||
# 例如:{"enable": True, "max_results": 5}
|
||||
web_search: Optional[Union[bool, Dict[str, Any]]] = Field(
|
||||
None, description="Enable or configure web search capabilities."
|
||||
)
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
httpx
|
||||
252
utils.py
Normal file
252
utils.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
from config import DEBUG
|
||||
|
||||
logger = logging.getLogger("proxy")
|
||||
|
||||
|
||||
class UpstreamAPIError(Exception):
|
||||
"""
|
||||
自定义异常类,表示与上游 API 通信时发生的错误;继承自 Exception ,增加 status_code 和 details 属性,方便格式统一
|
||||
"""
|
||||
def __init__(self, status_code: int, message: str, details: Any = None):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.details = details
|
||||
super().__init__(f"[{self.status_code}] {self.message}: {self.details}")
|
||||
|
||||
|
||||
async def handle_upstream_response(response: httpx.Response):
|
||||
"""
|
||||
辅助函数,检查上游 API 的 HTTP 响应;如果响应状态码表示失败(非 2xx),则会解析错误信息并抛出 UpstreamAPIError 异常
|
||||
"""
|
||||
|
||||
if response.is_success:
|
||||
return
|
||||
|
||||
status_code = response.status_code
|
||||
try:
|
||||
# 尝试将响应解析为 JSON 格式
|
||||
details = response.json()
|
||||
except json.JSONDecodeError:
|
||||
# 解析失败,直接截取文本作为错误详情
|
||||
details = response.text[:200]
|
||||
|
||||
# 预设常见 HTTP 错误状态码
|
||||
error_messages = {
|
||||
400: "Invalid request",
|
||||
401: "Authentication failed",
|
||||
403: "Forbidden",
|
||||
404: "Upstream resource not found",
|
||||
429: "Too many requests",
|
||||
500: "Upstream server internal error",
|
||||
502: "Upstream server gateway error",
|
||||
503: "Upstream service unavailable",
|
||||
504: "Upstream server gateway timeout",
|
||||
}
|
||||
# 不在预设则使用通用错误信息
|
||||
message = error_messages.get(status_code, "Unknown upstream API error")
|
||||
|
||||
raise UpstreamAPIError(status_code=status_code, message=message, details=details)
|
||||
|
||||
|
||||
async def generate_error_sse(error: UpstreamAPIError, request_id: str):
|
||||
"""
|
||||
捕获到 UpstreamAPIError 时,生成符合 SSE 错误响应
|
||||
"""
|
||||
error_content = {
|
||||
"error": {
|
||||
"message": f"Upstream API Error: {error.message}",
|
||||
"code": error.status_code,
|
||||
"details": error.details
|
||||
}
|
||||
}
|
||||
# 使用 json.dumps 将错误详情字典转换为 JSON 字符串
|
||||
formatted_data = json.dumps(error_content, ensure_ascii=False)
|
||||
logger.debug(
|
||||
f"[{request_id}] Yielding Error to Client:\n"
|
||||
f"event: error\n"
|
||||
f"data: {formatted_data}\n\n"
|
||||
)
|
||||
yield f"event: error\n"
|
||||
yield f"data: {formatted_data}\n\n"
|
||||
|
||||
|
||||
async def generate_network_error_sse(exc: httpx.RequestError, request_id: str):
|
||||
"""
|
||||
捕获到 httpx 网络请求错误时,生成符合 SSE 错误响应
|
||||
"""
|
||||
error_content = {
|
||||
"error": {
|
||||
"message": f"Network request error: {exc}",
|
||||
"code": "network_error"
|
||||
}
|
||||
}
|
||||
formatted_data = json.dumps(error_content, ensure_ascii=False)
|
||||
logger.debug(
|
||||
f"[{request_id}] Yielding Network Error to Client:\n"
|
||||
f"event: error\n"
|
||||
f"data: {formatted_data}\n\n"
|
||||
)
|
||||
yield f"event: error\n"
|
||||
yield f"data: {formatted_data}\n\n"
|
||||
|
||||
|
||||
def convert_prompt_to_messages(prompt: List[str]) -> List[Dict[str, str]]:
|
||||
"""
|
||||
将自定义的 prompt 列表格式转换为符合 OpenAI API 标准的 messages 格式
|
||||
- prompt 列表的第一个元素被视为 system 角色的内容
|
||||
- prompt 列表的其余所有元素被合并,并视为 user 角色的内容
|
||||
- 如果 prompt 只有一个元素,则直接视为 user 消息
|
||||
"""
|
||||
if not prompt:
|
||||
return []
|
||||
if len(prompt) == 1:
|
||||
return [{"role": "user", "content": prompt[0]}]
|
||||
|
||||
# 第一个元素作为 system message
|
||||
system_message = {"role": "system", "content": prompt[0]}
|
||||
# 后续所有元素合并成一个 user message
|
||||
user_content = "\n".join(prompt[1:])
|
||||
user_message = {"role": "user", "content": user_content}
|
||||
return [system_message, user_message]
|
||||
|
||||
def sanitize_mthreads_response(raw_text: str) -> str:
|
||||
"""
|
||||
清理 MThreads Qwen API 返回的无效内容
|
||||
- 拼接 <|FunctionCallBegin|>...<|FunctionCallEnd|> 中的内容
|
||||
- 移除各种形式的 <|...|>
|
||||
- 移除末尾可能残留的特殊字符 `]>`
|
||||
- 处理 Unicode 转义字符
|
||||
"""
|
||||
final_text_parts = []
|
||||
|
||||
def _extract_from_call(match):
|
||||
"""re.sub 的回调函数,用于处理函数调用标记"""
|
||||
call_content = match.group(1)
|
||||
if not call_content or call_content == "[]":
|
||||
return ""
|
||||
try:
|
||||
call_json = json.loads(call_content)
|
||||
if call_json.get("name") == "send_message":
|
||||
args_str = call_json.get("arguments", "{}")
|
||||
args_json = json.loads(args_str)
|
||||
message_text = args_json.get("text", "")
|
||||
if message_text:
|
||||
final_text_parts.append(message_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# 解析失败,直接忽略这个函数调用块
|
||||
pass
|
||||
return ""
|
||||
|
||||
func_call_pattern = re.compile(
|
||||
r"<\|FunctionCallBegin\|>(.*?)<\|FunctionCallEnd\|>", re.DOTALL
|
||||
)
|
||||
|
||||
main_text = func_call_pattern.sub(_extract_from_call, raw_text)
|
||||
|
||||
main_text = re.sub(r"<\|.*?\|>", "", main_text)
|
||||
|
||||
main_text = main_text.strip().rstrip(']>')
|
||||
|
||||
if main_text.strip():
|
||||
final_text_parts.insert(0, main_text.strip())
|
||||
|
||||
processed_text = "\n".join(final_text_parts).strip()
|
||||
|
||||
# 处理双重转义字符;用 latin-1 编码后再用 unicode-escape 解码修复
|
||||
return processed_text.encode("latin-1", "backslashreplace").decode("unicode-escape")
|
||||
|
||||
|
||||
async def standard_sse_generator(
|
||||
upstream_url: str,
|
||||
payload: dict,
|
||||
headers: dict,
|
||||
service_name: str,
|
||||
request_id: str,
|
||||
):
|
||||
"""
|
||||
用于处理与 OpenAI-like API 流式通信的异步生成器;向上游服务发送请求,并以 Server-Sent Events (SSE) 的格式流式返回响应
|
||||
|
||||
参数:
|
||||
- upstream_url:上游 API 的地址
|
||||
- payload:发送给上游 API 的请求体 (JSON)
|
||||
- headers:请求头,通常包含认证信息
|
||||
- service_name:服务名称,日志追踪
|
||||
- request_id:当前请求的唯一 ID,日志追踪
|
||||
"""
|
||||
try:
|
||||
# 使用 httpx.AsyncClient 以支持异步和连接池
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0)) as client:
|
||||
# 使用 client.stream 发起流式请求
|
||||
async with client.stream(
|
||||
"POST", upstream_url, json=payload, headers=headers
|
||||
) as response:
|
||||
# 检查初始响应头,如果状态码非 2xx 则会抛出异常
|
||||
await handle_upstream_response(response)
|
||||
|
||||
accumulated_content = []
|
||||
# 异步迭代来自服务器的每一行数据
|
||||
async for line in response.aiter_lines():
|
||||
|
||||
# SSE 事件通常以 "data:" 开头
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
# 提取 data: 后面的内容
|
||||
data_str = line.split(":", 1)[1].strip()
|
||||
|
||||
# SSE 流以 [DONE] 标记结束
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
data_json = json.loads(data_str)
|
||||
# 遵循 OpenAI 的格式,从 choices[0].delta.content 中提取文本块
|
||||
delta = data_json.get("choices", [{}])[0].get("delta", {})
|
||||
content_chunk = delta.get("content")
|
||||
if content_chunk:
|
||||
accumulated_content.append(content_chunk)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
# 如果某一行不是有效的 JSON 或结构不符合预期,则忽略
|
||||
pass
|
||||
|
||||
# 将所有接收到的文本块拼接成最终的完整文本
|
||||
final_text = "".join(accumulated_content)
|
||||
if not final_text:
|
||||
final_text = "[No content returned from model]"
|
||||
logger.debug(f"[{request_id}] Final Combined Text: '{final_text}'")
|
||||
|
||||
# 将最终文本包装成我们统一的 SSE 'complete' 事件格式
|
||||
formatted_data = json.dumps([final_text], ensure_ascii=False)
|
||||
|
||||
chunk_for_client_event = f"event: complete\n"
|
||||
chunk_for_client_data = f"data: {formatted_data}\n\n"
|
||||
|
||||
logger.debug(
|
||||
f"[{request_id}] Yielding to Client:\n{chunk_for_client_event}{chunk_for_client_data}"
|
||||
)
|
||||
|
||||
# 产出最终的 SSE 事件
|
||||
yield chunk_for_client_event
|
||||
yield chunk_for_client_data
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
# 处理网络层面的错误
|
||||
async for item in generate_network_error_sse(exc, request_id):
|
||||
yield item
|
||||
except UpstreamAPIError as exc:
|
||||
# 处理应用层面的上游 API 错误
|
||||
async for item in generate_error_sse(exc, request_id):
|
||||
yield item
|
||||
except Exception as exc:
|
||||
# 处理其他所有未预料到的异常
|
||||
error = UpstreamAPIError(
|
||||
status_code=500, message="An unknown error occurred in the proxy server", details=str(exc)
|
||||
)
|
||||
async for item in generate_error_sse(error, request_id):
|
||||
yield item
|
||||
Reference in New Issue
Block a user