update sth at 2025-10-23 17:38:54
Browse files- deploy/.dockerignore → .dockerignore +0 -0
- .env +5 -43
- .env.example +24 -42
- Dockerfile +6 -4
- deploy/NGINX_SETUP.md → NGINX_SETUP.md +4 -4
- deploy/README_DOCKER.md → README_DOCKER.md +1 -1
- app/core/config.py +5 -13
- app/core/openai.py +4 -0
- app/providers/zai_provider.py +151 -105
- app/utils/__init__.py +2 -2
- app/utils/signature.py +56 -0
- app/utils/sse_tool_handler.py +0 -612
- deploy/.env.example +0 -35
- deploy/Dockerfile +0 -24
- deploy/docker-compose.yml → docker-compose.yml +0 -0
- deploy/nginx.conf.example → nginx.conf.example +0 -0
- tests/test_signature.py +19 -0
- tests/test_simple_signature.py +36 -0
deploy/.dockerignore → .dockerignore
RENAMED
|
File without changes
|
.env
CHANGED
|
@@ -1,54 +1,16 @@
|
|
| 1 |
-
#
|
| 2 |
-
#
|
| 3 |
|
| 4 |
-
#
|
| 5 |
-
|
| 6 |
-
AUTH_TOKEN=sk-your-api-key
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
# 跳过客户端认证(仅开发环境使用)
|
| 10 |
-
SKIP_AUTH_TOKEN=false
|
| 11 |
-
|
| 12 |
-
# ========== Z.ai Token池配置 ==========
|
| 13 |
-
# Token失败阈值(失败多少次后标记为不可用)
|
| 14 |
-
TOKEN_FAILURE_THRESHOLD=3
|
| 15 |
-
|
| 16 |
-
# Token恢复超时时间(秒,失败token在此时间后重新尝试)
|
| 17 |
-
TOKEN_RECOVERY_TIMEOUT=1800
|
| 18 |
|
| 19 |
# Z.AI 匿名用户模式
|
| 20 |
# false: 使用认证 Token 令牌,失败时自动降级为匿名请求
|
| 21 |
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
| 22 |
ANONYMOUS_MODE=true
|
| 23 |
|
| 24 |
-
# ========== LongCat 配置 ==========
|
| 25 |
-
# LongCat token(单个token)
|
| 26 |
-
# LONGCAT_TOKEN=your_passport_token_here
|
| 27 |
-
|
| 28 |
-
# ========== 服务器配置 ==========
|
| 29 |
# 服务监听端口
|
| 30 |
LISTEN_PORT=7860
|
| 31 |
|
| 32 |
-
# 服务名称
|
| 33 |
-
SERVICE_NAME=z-ai2api-server
|
| 34 |
-
|
| 35 |
# 调试日志
|
| 36 |
-
DEBUG_LOGGING=
|
| 37 |
-
|
| 38 |
-
# Nginx 反向代理路径前缀(可选,用于在子路径下部署)
|
| 39 |
-
# 例如:ROOT_PATH=/ai2api 则服务部署在 http://domain.com/ai2api
|
| 40 |
-
# 留空表示部署在根路径
|
| 41 |
-
ROOT_PATH=/api
|
| 42 |
-
|
| 43 |
-
# Function Call 功能开关
|
| 44 |
-
TOOL_SUPPORT=true
|
| 45 |
-
|
| 46 |
-
# 工具调用扫描限制(字符数)
|
| 47 |
-
SCAN_LIMIT=200000
|
| 48 |
-
|
| 49 |
-
# ========== 管理后台认证 ==========
|
| 50 |
-
# 管理后台登录密码(建议修改为复杂密码)
|
| 51 |
-
ADMIN_PASSWORD=admin123
|
| 52 |
-
|
| 53 |
-
# Session 密钥(用于加密会话,建议生成随机字符串)
|
| 54 |
-
SESSION_SECRET_KEY=your-secret-key-change-in-production
|
|
|
|
| 1 |
+
# 代理服务配置文件
|
| 2 |
+
# 匿名模式配置
|
| 3 |
|
| 4 |
+
# 跳过客户端认证(启用匿名访问)
|
| 5 |
+
SKIP_AUTH_TOKEN=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# Z.AI 匿名用户模式
|
| 8 |
# false: 使用认证 Token 令牌,失败时自动降级为匿名请求
|
| 9 |
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
| 10 |
ANONYMOUS_MODE=true
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# 服务监听端口
|
| 13 |
LISTEN_PORT=7860
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
# 调试日志
|
| 16 |
+
DEBUG_LOGGING=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.env.example
CHANGED
|
@@ -1,53 +1,35 @@
|
|
| 1 |
-
#
|
| 2 |
-
#
|
|
|
|
| 3 |
|
| 4 |
-
#
|
| 5 |
-
|
| 6 |
-
AUTH_TOKEN=sk-your-api-key
|
| 7 |
-
|
| 8 |
-
# 跳过客户端认证(仅开发环境使用)
|
| 9 |
-
SKIP_AUTH_TOKEN=false
|
| 10 |
-
|
| 11 |
-
# ========== Z.ai Token池配置 ==========
|
| 12 |
-
# Token失败阈值(失败多少次后标记为不可用)
|
| 13 |
-
TOKEN_FAILURE_THRESHOLD=3
|
| 14 |
-
|
| 15 |
-
# Token恢复超时时间(秒,失败token在此时间后重新尝试)
|
| 16 |
-
TOKEN_RECOVERY_TIMEOUT=1800
|
| 17 |
-
|
| 18 |
-
# Z.AI 匿名用户模式
|
| 19 |
-
# false: 使用认证 Token 令牌,失败时自动降级为匿名请求
|
| 20 |
-
# true: 自动从 Z.ai 获取临时访问令牌,避免对话历史共享
|
| 21 |
-
ANONYMOUS_MODE=true
|
| 22 |
-
|
| 23 |
-
# ========== LongCat 配置 ==========
|
| 24 |
-
# LongCat token(单个token)
|
| 25 |
-
# LONGCAT_TOKEN=your_passport_token_here
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
LISTEN_PORT=7860
|
| 30 |
|
| 31 |
-
#
|
| 32 |
-
|
| 33 |
|
| 34 |
-
# 调试日志
|
| 35 |
-
DEBUG_LOGGING=
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
# 留空表示部署在根路径
|
| 40 |
-
ROOT_PATH=
|
| 41 |
|
| 42 |
-
# Function Call 功能开关
|
| 43 |
TOOL_SUPPORT=true
|
| 44 |
|
| 45 |
-
#
|
| 46 |
SCAN_LIMIT=200000
|
| 47 |
|
| 48 |
-
#
|
| 49 |
-
|
| 50 |
-
ADMIN_PASSWORD=admin123
|
| 51 |
|
| 52 |
-
#
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================
|
| 2 |
+
# Z.AI API Server - Docker 环境变量配置示例
|
| 3 |
+
# ==============================================
|
| 4 |
|
| 5 |
+
# 管理后台密码
|
| 6 |
+
ADMIN_PASSWORD=admin123
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
+
# API 认证密钥 (用于验证客户端请求)
|
| 9 |
+
AUTH_TOKEN=sk-your-api-key-here
|
|
|
|
| 10 |
|
| 11 |
+
# 是否跳过 API Key 验证 (开发环境可设为 true)
|
| 12 |
+
SKIP_AUTH_TOKEN=false
|
| 13 |
|
| 14 |
+
# 调试日志 (生产环境建议设为 false)
|
| 15 |
+
DEBUG_LOGGING=true
|
| 16 |
|
| 17 |
+
# 匿名模式 (允许无 token 访问,需要配合 SKIP_AUTH_TOKEN=true)
|
| 18 |
+
ANONYMOUS_MODE=false
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
# Function Call 功能开关 (是否支持工具调用)
|
| 21 |
TOOL_SUPPORT=true
|
| 22 |
|
| 23 |
+
# 工具调用扫描限制 (字符数)
|
| 24 |
SCAN_LIMIT=200000
|
| 25 |
|
| 26 |
+
# 数据库路径 (Docker 环境使用持久化卷)
|
| 27 |
+
DB_PATH=/app/data/tokens.db
|
|
|
|
| 28 |
|
| 29 |
+
# Token 池配置
|
| 30 |
+
TOKEN_FAILURE_THRESHOLD=3
|
| 31 |
+
TOKEN_RECOVERY_TIMEOUT=300
|
| 32 |
+
|
| 33 |
+
# 服务配置
|
| 34 |
+
SERVICE_NAME=Z.AI_API_Server
|
| 35 |
+
LISTEN_PORT=7860
|
Dockerfile
CHANGED
|
@@ -10,20 +10,22 @@ RUN mkdir -p /app/data /app/logs && \
|
|
| 10 |
# Install dependencies
|
| 11 |
COPY requirements.txt .
|
| 12 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
-
|
| 14 |
# Copy application code
|
| 15 |
COPY . .
|
| 16 |
|
| 17 |
# Set environment variable for database path
|
| 18 |
ENV DB_PATH=/app/data/tokens.db
|
| 19 |
-
ENV
|
| 20 |
-
ENV ROOT_PATH=/api
|
| 21 |
ENV ANONYMOUS_MODE=true
|
|
|
|
| 22 |
ENV LISTEN_PORT=7860
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
# Expose port
|
| 26 |
EXPOSE 7860
|
| 27 |
|
|
|
|
| 28 |
# Run the application
|
| 29 |
CMD ["python", "main.py"]
|
|
|
|
| 10 |
# Install dependencies
|
| 11 |
COPY requirements.txt .
|
| 12 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
# Copy application code
|
| 15 |
COPY . .
|
| 16 |
|
| 17 |
# Set environment variable for database path
|
| 18 |
ENV DB_PATH=/app/data/tokens.db
|
| 19 |
+
ENV SKIP_AUTH_TOKEN=true
|
|
|
|
| 20 |
ENV ANONYMOUS_MODE=true
|
| 21 |
+
# 服务监听端口
|
| 22 |
ENV LISTEN_PORT=7860
|
| 23 |
+
# 调试日志
|
| 24 |
+
ENV DEBUG_LOGGING=true
|
| 25 |
|
| 26 |
# Expose port
|
| 27 |
EXPOSE 7860
|
| 28 |
|
| 29 |
+
|
| 30 |
# Run the application
|
| 31 |
CMD ["python", "main.py"]
|
deploy/NGINX_SETUP.md → NGINX_SETUP.md
RENAMED
|
@@ -37,7 +37,7 @@ server {
|
|
| 37 |
|
| 38 |
location /ai2api {
|
| 39 |
# 代理到后端服务
|
| 40 |
-
proxy_pass http://127.0.0.1:
|
| 41 |
|
| 42 |
# 传递原始请求信息
|
| 43 |
proxy_set_header Host $host;
|
|
@@ -257,9 +257,9 @@ server {
|
|
| 257 |
|
| 258 |
```nginx
|
| 259 |
upstream ai2api_backend {
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
}
|
| 264 |
|
| 265 |
server {
|
|
|
|
| 37 |
|
| 38 |
location /ai2api {
|
| 39 |
# 代理到后端服务
|
| 40 |
+
proxy_pass http://127.0.0.1:7860;
|
| 41 |
|
| 42 |
# 传递原始请求信息
|
| 43 |
proxy_set_header Host $host;
|
|
|
|
| 257 |
|
| 258 |
```nginx
|
| 259 |
upstream ai2api_backend {
|
| 260 |
+
server 127.0.0.1:7860;
|
| 261 |
+
server 127.0.0.1:8081;
|
| 262 |
+
server 127.0.0.1:8082;
|
| 263 |
}
|
| 264 |
|
| 265 |
server {
|
deploy/README_DOCKER.md → README_DOCKER.md
RENAMED
|
@@ -225,7 +225,7 @@ docker compose config
|
|
| 225 |
如端口 7860 被占用,修改 `docker-compose.yml`:
|
| 226 |
```yaml
|
| 227 |
ports:
|
| 228 |
-
- "
|
| 229 |
```
|
| 230 |
|
| 231 |
### 健康检查失败
|
|
|
|
| 225 |
如端口 7860 被占用,修改 `docker-compose.yml`:
|
| 226 |
```yaml
|
| 227 |
ports:
|
| 228 |
+
- "8081:7860" # 映射到宿主机 8081 端口
|
| 229 |
```
|
| 230 |
|
| 231 |
### 健康检查失败
|
app/core/config.py
CHANGED
|
@@ -70,23 +70,15 @@ class Settings(BaseSettings):
|
|
| 70 |
# Provider Configuration
|
| 71 |
DEFAULT_PROVIDER: str = os.getenv("DEFAULT_PROVIDER", "zai") # 默认提供商:zai/k2think/longcat
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
# Admin Panel Authentication
|
| 74 |
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") # 管理后台密码
|
| 75 |
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY", "your-secret-key-change-in-production") # Session 密钥
|
| 76 |
|
| 77 |
-
# Browser Headers
|
| 78 |
-
CLIENT_HEADERS: Dict[str, str] = {
|
| 79 |
-
"Content-Type": "application/json",
|
| 80 |
-
"Accept": "application/json, text/event-stream",
|
| 81 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",
|
| 82 |
-
"Accept-Language": "zh-CN",
|
| 83 |
-
"sec-ch-ua": '"Not;A=Brand";v="99", "Microsoft Edge";v="139", "Chromium";v="139"',
|
| 84 |
-
"sec-ch-ua-mobile": "?0",
|
| 85 |
-
"sec-ch-ua-platform": '"Windows"',
|
| 86 |
-
"X-FE-Version": "prod-fe-1.0.98",
|
| 87 |
-
"Origin": "https://chat.z.ai",
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
class Config:
|
| 91 |
env_file = ".env"
|
| 92 |
extra = "ignore" # 忽略额外字段,防止环境变量中的未知字段导致验证错误
|
|
|
|
| 70 |
# Provider Configuration
|
| 71 |
DEFAULT_PROVIDER: str = os.getenv("DEFAULT_PROVIDER", "zai") # 默认提供商:zai/k2think/longcat
|
| 72 |
|
| 73 |
+
# Proxy Configuration
|
| 74 |
+
HTTP_PROXY: Optional[str] = os.getenv("HTTP_PROXY") # HTTP代理,格式: http://user:pass@host:port 或 http://host:port
|
| 75 |
+
HTTPS_PROXY: Optional[str] = os.getenv("HTTPS_PROXY") # HTTPS代理,格式同上
|
| 76 |
+
SOCKS5_PROXY: Optional[str] = os.getenv("SOCKS5_PROXY") # SOCKS5代理,格式: socks5://user:pass@host:port
|
| 77 |
+
|
| 78 |
# Admin Panel Authentication
|
| 79 |
ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") # 管理后台密码
|
| 80 |
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY", "your-secret-key-change-in-production") # Session 密钥
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
class Config:
|
| 83 |
env_file = ".env"
|
| 84 |
extra = "ignore" # 忽略额外字段,防止环境变量中的未知字段导致验证错误
|
app/core/openai.py
CHANGED
|
@@ -93,6 +93,8 @@ async def handle_non_stream_response(stream_response, request: OpenAIRequest) ->
|
|
| 93 |
|
| 94 |
|
| 95 |
@router.get("/v1/models")
|
|
|
|
|
|
|
| 96 |
async def list_models():
|
| 97 |
"""List available models from all providers"""
|
| 98 |
try:
|
|
@@ -115,6 +117,8 @@ async def list_models():
|
|
| 115 |
|
| 116 |
|
| 117 |
@router.post("/v1/chat/completions")
|
|
|
|
|
|
|
| 118 |
async def chat_completions(request: OpenAIRequest, authorization: str = Header(...)):
|
| 119 |
"""Handle chat completion requests with multi-provider architecture"""
|
| 120 |
role = request.messages[0].role if request.messages else "unknown"
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
@router.get("/v1/models")
|
| 96 |
+
@router.get("/hf/v1/models")
|
| 97 |
+
@router.get("/api/v1/models")
|
| 98 |
async def list_models():
|
| 99 |
"""List available models from all providers"""
|
| 100 |
try:
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
@router.post("/v1/chat/completions")
|
| 120 |
+
@router.post("/hf/v1/chat/completions")
|
| 121 |
+
@router.post("/api/v1/chat/completions")
|
| 122 |
async def chat_completions(request: OpenAIRequest, authorization: str = Header(...)):
|
| 123 |
"""Handle chat completion requests with multi-provider architecture"""
|
| 124 |
role = request.messages[0].role if request.messages else "unknown"
|
app/providers/zai_provider.py
CHANGED
|
@@ -12,6 +12,7 @@ import httpx
|
|
| 12 |
import hmac
|
| 13 |
import hashlib
|
| 14 |
import base64
|
|
|
|
| 15 |
from urllib.parse import urlencode
|
| 16 |
import os
|
| 17 |
import uuid
|
|
@@ -19,6 +20,7 @@ import random
|
|
| 19 |
from datetime import datetime
|
| 20 |
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 21 |
from app.utils.user_agent import get_random_user_agent
|
|
|
|
| 22 |
from app.providers.base import BaseProvider, ProviderConfig
|
| 23 |
from app.models.schemas import OpenAIRequest, Message
|
| 24 |
from app.core.config import settings
|
|
@@ -68,7 +70,7 @@ def get_zai_dynamic_headers(chat_id: str = "") -> Dict[str, str]:
|
|
| 68 |
"Cache-Control": "no-cache",
|
| 69 |
"User-Agent": user_agent,
|
| 70 |
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 71 |
-
"X-FE-Version": "prod-fe-1.0.
|
| 72 |
"Origin": "https://chat.z.ai",
|
| 73 |
}
|
| 74 |
|
|
@@ -116,26 +118,6 @@ def _extract_user_id_from_token(token: str) -> str:
|
|
| 116 |
return "guest"
|
| 117 |
|
| 118 |
|
| 119 |
-
def generate_signature(message_text: str, request_id: str, timestamp_ms: int, user_id: str, secret: str = "junjie") -> str:
|
| 120 |
-
"""Dual-layer HMAC-SHA256 signature.
|
| 121 |
-
|
| 122 |
-
Layer1: derived key = HMAC(secret, window_index)
|
| 123 |
-
Layer2: signature = HMAC(derived_key, canonical_string)
|
| 124 |
-
canonical_string = "requestId,<id>,timestamp,<ts>,user_id,<uid>|<msg>|<ts>"
|
| 125 |
-
"""
|
| 126 |
-
r = str(timestamp_ms)
|
| 127 |
-
e = f"requestId,{request_id},timestamp,{timestamp_ms},user_id,{user_id}"
|
| 128 |
-
t = message_text or ""
|
| 129 |
-
# Add content_base64 processing for new signature algorithm
|
| 130 |
-
content_base64 = base64.b64encode(t.encode('utf-8')).decode('ascii')
|
| 131 |
-
i = f"{e}|{content_base64}|{r}"
|
| 132 |
-
|
| 133 |
-
window_index = timestamp_ms // (5 * 60 * 1000)
|
| 134 |
-
root_key = (secret or "junjie").encode("utf-8")
|
| 135 |
-
derived_hex = hmac.new(root_key, str(window_index).encode("utf-8"), hashlib.sha256).hexdigest()
|
| 136 |
-
signature = hmac.new(derived_hex.encode("utf-8"), i.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 137 |
-
return signature
|
| 138 |
-
|
| 139 |
|
| 140 |
class ZAIProvider(BaseProvider):
|
| 141 |
"""Z.AI 提供商"""
|
|
@@ -179,30 +161,94 @@ class ZAIProvider(BaseProvider):
|
|
| 179 |
settings.GLM46_SEARCH_MODEL,
|
| 180 |
settings.GLM46_ADVANCED_SEARCH_MODEL,
|
| 181 |
]
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
async def get_token(self) -> str:
|
| 184 |
"""获取认证令牌"""
|
| 185 |
# 如果启用匿名模式,只尝试获取访客令牌
|
| 186 |
if settings.ANONYMOUS_MODE:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
# 匿名模式下,如果获取访客令牌失败,直接返回空
|
| 205 |
-
self.logger.error("❌
|
| 206 |
return ""
|
| 207 |
|
| 208 |
# 非匿名模式:首先使用token池获取备份令牌
|
|
@@ -253,7 +299,7 @@ class ZAIProvider(BaseProvider):
|
|
| 253 |
|
| 254 |
self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
|
| 255 |
|
| 256 |
-
# 构建上传请求
|
| 257 |
upload_url = f"{self.base_url}/api/v1/files/"
|
| 258 |
headers = {
|
| 259 |
"Accept": "*/*",
|
|
@@ -273,8 +319,11 @@ class ZAIProvider(BaseProvider):
|
|
| 273 |
"Authorization": f"Bearer {token}",
|
| 274 |
}
|
| 275 |
|
|
|
|
|
|
|
|
|
|
| 276 |
# 使用 httpx 上传文件
|
| 277 |
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 278 |
files = {
|
| 279 |
"file": (filename, image_data, mime_type)
|
| 280 |
}
|
|
@@ -501,12 +550,8 @@ class ZAIProvider(BaseProvider):
|
|
| 501 |
if is_advanced_search:
|
| 502 |
mcp_servers.append("advanced-search")
|
| 503 |
self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
|
| 504 |
-
elif is_search and "-4.5" in requested_model:
|
| 505 |
-
mcp_servers.append("deep-web-search")
|
| 506 |
-
self.logger.info("🔍 检测到搜索模型,添加 deep-web-search MCP 服务器")
|
| 507 |
-
|
| 508 |
-
# 构建上游请求体(chat_id 已在前面生成)
|
| 509 |
|
|
|
|
| 510 |
body = {
|
| 511 |
"stream": True, # 总是使用流式
|
| 512 |
"model": upstream_model_id,
|
|
@@ -587,34 +632,48 @@ class ZAIProvider(BaseProvider):
|
|
| 587 |
if request.max_tokens is not None:
|
| 588 |
body["params"]["max_tokens"] = request.max_tokens
|
| 589 |
|
| 590 |
-
# 构建请求头
|
| 591 |
-
headers = get_zai_dynamic_headers(chat_id)
|
| 592 |
-
if token:
|
| 593 |
-
headers["Authorization"] = f"Bearer {token}"
|
| 594 |
-
|
| 595 |
# Dual-layer HMAC signing metadata and header
|
| 596 |
user_id = _extract_user_id_from_token(token)
|
| 597 |
timestamp_ms = int(time.time() * 1000)
|
| 598 |
request_id = generate_uuid()
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
query_params = {
|
| 608 |
-
"timestamp": timestamp_ms,
|
| 609 |
"requestId": request_id,
|
| 610 |
"user_id": user_id,
|
| 611 |
-
"token": token
|
|
|
|
|
|
|
| 612 |
"current_url": f"https://chat.z.ai/c/{chat_id}",
|
| 613 |
"pathname": f"/c/{chat_id}",
|
| 614 |
-
"signature_timestamp": timestamp_ms,
|
| 615 |
}
|
| 616 |
signed_url = f"{self.config.api_endpoint}?{urlencode(query_params)}"
|
| 617 |
-
|
|
|
|
|
|
|
|
|
|
| 618 |
|
| 619 |
# 存储当前token用于错误处理
|
| 620 |
self._current_token = token
|
|
@@ -645,8 +704,11 @@ class ZAIProvider(BaseProvider):
|
|
| 645 |
# 流式响应
|
| 646 |
return self._create_stream_response(request, transformed)
|
| 647 |
else:
|
|
|
|
|
|
|
|
|
|
| 648 |
# 非流式响应
|
| 649 |
-
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 650 |
response = await client.post(
|
| 651 |
transformed["url"],
|
| 652 |
headers=transformed["headers"],
|
|
@@ -673,9 +735,13 @@ class ZAIProvider(BaseProvider):
|
|
| 673 |
|
| 674 |
current_token = transformed.get("token", "")
|
| 675 |
try:
|
|
|
|
|
|
|
|
|
|
| 676 |
async with httpx.AsyncClient(
|
| 677 |
timeout=60.0,
|
| 678 |
http2=True,
|
|
|
|
| 679 |
) as client:
|
| 680 |
self.logger.info(f"🎯 发送请求到 Z.AI: {transformed['url']}")
|
| 681 |
# self.logger.info(f"📦 请求体 model: {transformed['body']['model']}")
|
|
@@ -692,13 +758,25 @@ class ZAIProvider(BaseProvider):
|
|
| 692 |
error_msg = error_text.decode('utf-8', errors='ignore')
|
| 693 |
if error_msg:
|
| 694 |
self.logger.error(f"❌ 错误详情: {error_msg}")
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
}
|
| 701 |
-
}
|
| 702 |
yield f"data: {json.dumps(error_response)}\n\n"
|
| 703 |
yield "data: [DONE]\n\n"
|
| 704 |
return
|
|
@@ -859,10 +937,9 @@ class ZAIProvider(BaseProvider):
|
|
| 859 |
|
| 860 |
# 尝试从缓冲区提取 tool_calls
|
| 861 |
tool_calls = None
|
| 862 |
-
cleaned_content = buffered_content
|
| 863 |
|
| 864 |
if has_tools:
|
| 865 |
-
tool_calls,
|
| 866 |
|
| 867 |
if tool_calls:
|
| 868 |
# 发现工具调用
|
|
@@ -909,28 +986,8 @@ class ZAIProvider(BaseProvider):
|
|
| 909 |
yield "data: [DONE]\n\n"
|
| 910 |
|
| 911 |
else:
|
| 912 |
-
#
|
| 913 |
-
#
|
| 914 |
-
if edit_content and "</details>\n" in edit_content:
|
| 915 |
-
if has_thinking:
|
| 916 |
-
# 发送思考签名
|
| 917 |
-
thinking_signature = str(int(time.time() * 1000))
|
| 918 |
-
sig_chunk = self.create_openai_chunk(
|
| 919 |
-
chat_id,
|
| 920 |
-
model,
|
| 921 |
-
{
|
| 922 |
-
"role": "assistant",
|
| 923 |
-
"thinking": {
|
| 924 |
-
"content": "",
|
| 925 |
-
"signature": thinking_signature,
|
| 926 |
-
}
|
| 927 |
-
}
|
| 928 |
-
)
|
| 929 |
-
yield await self.format_sse_chunk(sig_chunk)
|
| 930 |
-
|
| 931 |
-
# 提取答案内容
|
| 932 |
-
cleaned_content = edit_content.split("</details>\n")[-1]
|
| 933 |
-
|
| 934 |
if not has_sent_role and not has_thinking:
|
| 935 |
role_chunk = self.create_openai_chunk(
|
| 936 |
chat_id,
|
|
@@ -940,17 +997,6 @@ class ZAIProvider(BaseProvider):
|
|
| 940 |
yield await self.format_sse_chunk(role_chunk)
|
| 941 |
has_sent_role = True
|
| 942 |
|
| 943 |
-
if cleaned_content:
|
| 944 |
-
content_chunk = self.create_openai_chunk(
|
| 945 |
-
chat_id,
|
| 946 |
-
model,
|
| 947 |
-
{
|
| 948 |
-
"role": "assistant",
|
| 949 |
-
"content": cleaned_content
|
| 950 |
-
}
|
| 951 |
-
)
|
| 952 |
-
yield await self.format_sse_chunk(content_chunk)
|
| 953 |
-
|
| 954 |
finish_chunk = self.create_openai_chunk(
|
| 955 |
chat_id,
|
| 956 |
model,
|
|
|
|
| 12 |
import hmac
|
| 13 |
import hashlib
|
| 14 |
import base64
|
| 15 |
+
import asyncio
|
| 16 |
from urllib.parse import urlencode
|
| 17 |
import os
|
| 18 |
import uuid
|
|
|
|
| 20 |
from datetime import datetime
|
| 21 |
from typing import Dict, List, Any, Optional, AsyncGenerator, Union
|
| 22 |
from app.utils.user_agent import get_random_user_agent
|
| 23 |
+
from app.utils.signature import generate_signature
|
| 24 |
from app.providers.base import BaseProvider, ProviderConfig
|
| 25 |
from app.models.schemas import OpenAIRequest, Message
|
| 26 |
from app.core.config import settings
|
|
|
|
| 70 |
"Cache-Control": "no-cache",
|
| 71 |
"User-Agent": user_agent,
|
| 72 |
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 73 |
+
"X-FE-Version": "prod-fe-1.0.106",
|
| 74 |
"Origin": "https://chat.z.ai",
|
| 75 |
}
|
| 76 |
|
|
|
|
| 118 |
return "guest"
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
class ZAIProvider(BaseProvider):
|
| 123 |
"""Z.AI 提供商"""
|
|
|
|
| 161 |
settings.GLM46_SEARCH_MODEL,
|
| 162 |
settings.GLM46_ADVANCED_SEARCH_MODEL,
|
| 163 |
]
|
| 164 |
+
|
| 165 |
+
def _get_proxy_config(self) -> Optional[str]:
|
| 166 |
+
"""Get proxy configuration from settings"""
|
| 167 |
+
# In httpx 0.28.1, proxy parameter expects a single URL string
|
| 168 |
+
# Support HTTP_PROXY, HTTPS_PROXY and SOCKS5_PROXY
|
| 169 |
+
|
| 170 |
+
if settings.HTTPS_PROXY:
|
| 171 |
+
self.logger.info(f"🔄 使用HTTPS代理: {settings.HTTPS_PROXY}")
|
| 172 |
+
return settings.HTTPS_PROXY
|
| 173 |
+
|
| 174 |
+
if settings.HTTP_PROXY:
|
| 175 |
+
self.logger.info(f"🔄 使用HTTP代理: {settings.HTTP_PROXY}")
|
| 176 |
+
return settings.HTTP_PROXY
|
| 177 |
+
|
| 178 |
+
if settings.SOCKS5_PROXY:
|
| 179 |
+
self.logger.info(f"🔄 使用SOCKS5代理: {settings.SOCKS5_PROXY}")
|
| 180 |
+
return settings.SOCKS5_PROXY
|
| 181 |
+
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
async def get_token(self) -> str:
|
| 185 |
"""获取认证令牌"""
|
| 186 |
# 如果启用匿名模式,只尝试获取访客令牌
|
| 187 |
if settings.ANONYMOUS_MODE:
|
| 188 |
+
max_retries = 3
|
| 189 |
+
retry_count = 0
|
| 190 |
+
|
| 191 |
+
while retry_count < max_retries:
|
| 192 |
+
try:
|
| 193 |
+
headers = get_zai_dynamic_headers()
|
| 194 |
+
self.logger.debug(f"尝试获取访客令牌 (第{retry_count + 1}次): {self.auth_url}")
|
| 195 |
+
self.logger.debug(f"请求头: {headers}")
|
| 196 |
+
|
| 197 |
+
# Get proxy configuration
|
| 198 |
+
proxies = self._get_proxy_config()
|
| 199 |
+
|
| 200 |
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True, proxy=proxies) as client:
|
| 201 |
+
response = await client.get(self.auth_url, headers=headers)
|
| 202 |
+
|
| 203 |
+
self.logger.debug(f"响应状态码: {response.status_code}")
|
| 204 |
+
self.logger.debug(f"响应头: {dict(response.headers)}")
|
| 205 |
+
|
| 206 |
+
if response.status_code == 200:
|
| 207 |
+
data = response.json()
|
| 208 |
+
self.logger.debug(f"响应数据: {data}")
|
| 209 |
+
|
| 210 |
+
token = data.get("token", "")
|
| 211 |
+
if token:
|
| 212 |
+
# 判断令牌类型(通过检查邮箱或user_id)
|
| 213 |
+
email = data.get("email", "")
|
| 214 |
+
is_guest = "@guest.com" in email or "Guest-" in email
|
| 215 |
+
token_type = "匿名用户" if is_guest else "认证用户"
|
| 216 |
+
self.logger.info(f"✅ 获取令牌成功 ({token_type}): {token[:20]}...")
|
| 217 |
+
return token
|
| 218 |
+
else:
|
| 219 |
+
self.logger.warning(f"响应中未找到token字段: {data}")
|
| 220 |
+
elif response.status_code == 405:
|
| 221 |
+
# WAF拦截
|
| 222 |
+
self.logger.error(f"🚫 请求被WAF拦截 (状态码405),请求头可能被识别为异常,请稍后重试...")
|
| 223 |
+
break
|
| 224 |
+
else:
|
| 225 |
+
self.logger.warning(f"HTTP请求失败,状态码: {response.status_code}")
|
| 226 |
+
try:
|
| 227 |
+
error_data = response.json()
|
| 228 |
+
self.logger.warning(f"错误响应: {error_data}")
|
| 229 |
+
except:
|
| 230 |
+
self.logger.warning(f"错误响应文本: {response.text}")
|
| 231 |
+
|
| 232 |
+
except httpx.TimeoutException as e:
|
| 233 |
+
self.logger.warning(f"请求超时 (第{retry_count + 1}次): {e}")
|
| 234 |
+
except httpx.ConnectError as e:
|
| 235 |
+
self.logger.warning(f"连接错误 (第{retry_count + 1}次): {e}")
|
| 236 |
+
except httpx.HTTPStatusError as e:
|
| 237 |
+
self.logger.warning(f"HTTP状态错误 (第{retry_count + 1}次): {e}")
|
| 238 |
+
except json.JSONDecodeError as e:
|
| 239 |
+
self.logger.warning(f"JSON解析错误 (第{retry_count + 1}次): {e}")
|
| 240 |
+
except Exception as e:
|
| 241 |
+
self.logger.warning(f"异步获取访客令牌失败 (第{retry_count + 1}次): {e}")
|
| 242 |
+
import traceback
|
| 243 |
+
self.logger.debug(f"错误堆栈: {traceback.format_exc()}")
|
| 244 |
+
|
| 245 |
+
retry_count += 1
|
| 246 |
+
if retry_count < max_retries:
|
| 247 |
+
self.logger.info(f"等待2秒后重试...")
|
| 248 |
+
await asyncio.sleep(2)
|
| 249 |
|
| 250 |
# 匿名模式下,如果获取访客令牌失败,直接返回空
|
| 251 |
+
self.logger.error("❌ 匿名模式下获取访客令牌失败,已重试3次")
|
| 252 |
return ""
|
| 253 |
|
| 254 |
# 非匿名模式:首先使用token池获取备份令牌
|
|
|
|
| 299 |
|
| 300 |
self.logger.debug(f"📤 上传图片: {filename}, 大小: {len(image_data)} bytes")
|
| 301 |
|
| 302 |
+
# 构建上传请求
|
| 303 |
upload_url = f"{self.base_url}/api/v1/files/"
|
| 304 |
headers = {
|
| 305 |
"Accept": "*/*",
|
|
|
|
| 319 |
"Authorization": f"Bearer {token}",
|
| 320 |
}
|
| 321 |
|
| 322 |
+
# Get proxy configuration
|
| 323 |
+
proxies = self._get_proxy_config()
|
| 324 |
+
|
| 325 |
# 使用 httpx 上传文件
|
| 326 |
+
async with httpx.AsyncClient(timeout=30.0, proxy=proxies) as client:
|
| 327 |
files = {
|
| 328 |
"file": (filename, image_data, mime_type)
|
| 329 |
}
|
|
|
|
| 550 |
if is_advanced_search:
|
| 551 |
mcp_servers.append("advanced-search")
|
| 552 |
self.logger.info("🔍 检测到高级搜索模型,添加 advanced-search MCP 服务器")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
|
| 554 |
+
# 构建上游请求体
|
| 555 |
body = {
|
| 556 |
"stream": True, # 总是使用流式
|
| 557 |
"model": upstream_model_id,
|
|
|
|
| 632 |
if request.max_tokens is not None:
|
| 633 |
body["params"]["max_tokens"] = request.max_tokens
|
| 634 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
# Dual-layer HMAC signing metadata and header
|
| 636 |
user_id = _extract_user_id_from_token(token)
|
| 637 |
timestamp_ms = int(time.time() * 1000)
|
| 638 |
request_id = generate_uuid()
|
| 639 |
+
try:
|
| 640 |
+
signing_metadata = f"requestId,{request_id},timestamp,{timestamp_ms},user_id,{user_id}"
|
| 641 |
+
prompt_for_signature = last_user_text or ""
|
| 642 |
+
signature_result = generate_signature(
|
| 643 |
+
e=signing_metadata,
|
| 644 |
+
t=prompt_for_signature,
|
| 645 |
+
s=timestamp_ms,
|
| 646 |
+
)
|
| 647 |
+
signature = signature_result["signature"]
|
| 648 |
+
logger.debug(f"[Z.AI] 生成签名成功: {signature[:16]}... (user_id={user_id}, request_id={request_id})")
|
| 649 |
+
except Exception as e:
|
| 650 |
+
logger.error(f"[Z.AI] 签名生成失败: {e}")
|
| 651 |
+
signature = ""
|
| 652 |
+
|
| 653 |
+
# 构建请求头 (匹配 X-FE-Version 和 X-Signature)
|
| 654 |
+
headers = {
|
| 655 |
+
"Authorization": f"Bearer {token}",
|
| 656 |
+
"Content-Type": "application/json",
|
| 657 |
+
"X-FE-Version": "prod-fe-1.0.106",
|
| 658 |
+
"X-Signature": signature,
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
query_params = {
|
| 662 |
+
"timestamp": str(timestamp_ms),
|
| 663 |
"requestId": request_id,
|
| 664 |
"user_id": user_id,
|
| 665 |
+
"token": token,
|
| 666 |
+
"version": "0.0.1",
|
| 667 |
+
"platform": "web",
|
| 668 |
"current_url": f"https://chat.z.ai/c/{chat_id}",
|
| 669 |
"pathname": f"/c/{chat_id}",
|
| 670 |
+
"signature_timestamp": str(timestamp_ms),
|
| 671 |
}
|
| 672 |
signed_url = f"{self.config.api_endpoint}?{urlencode(query_params)}"
|
| 673 |
+
|
| 674 |
+
# 记录请求详情用于调试
|
| 675 |
+
logger.debug(f"[Z.AI] 请求头: Authorization=Bearer *****, X-Signature={signature[:16] if signature else '(空)'}...")
|
| 676 |
+
logger.debug(f"[Z.AI] URL 参数: timestamp={timestamp_ms}, requestId={request_id}, user_id={user_id}")
|
| 677 |
|
| 678 |
# 存储当前token用于错误处理
|
| 679 |
self._current_token = token
|
|
|
|
| 704 |
# 流式响应
|
| 705 |
return self._create_stream_response(request, transformed)
|
| 706 |
else:
|
| 707 |
+
# Get proxy configuration
|
| 708 |
+
proxies = self._get_proxy_config()
|
| 709 |
+
|
| 710 |
# 非流式响应
|
| 711 |
+
async with httpx.AsyncClient(timeout=30.0, proxy=proxies) as client:
|
| 712 |
response = await client.post(
|
| 713 |
transformed["url"],
|
| 714 |
headers=transformed["headers"],
|
|
|
|
| 735 |
|
| 736 |
current_token = transformed.get("token", "")
|
| 737 |
try:
|
| 738 |
+
# Get proxy configuration
|
| 739 |
+
proxies = self._get_proxy_config()
|
| 740 |
+
|
| 741 |
async with httpx.AsyncClient(
|
| 742 |
timeout=60.0,
|
| 743 |
http2=True,
|
| 744 |
+
proxy=proxies,
|
| 745 |
) as client:
|
| 746 |
self.logger.info(f"🎯 发送请求到 Z.AI: {transformed['url']}")
|
| 747 |
# self.logger.info(f"📦 请求体 model: {transformed['body']['model']}")
|
|
|
|
| 758 |
error_msg = error_text.decode('utf-8', errors='ignore')
|
| 759 |
if error_msg:
|
| 760 |
self.logger.error(f"❌ 错误详情: {error_msg}")
|
| 761 |
+
|
| 762 |
+
# 特殊处理 405 状态码(WAF拦截)
|
| 763 |
+
if response.status_code == 405:
|
| 764 |
+
self.logger.error(f"🚫 请求被上游WAF拦截,可能是请求头或签名异常,请稍后重试...")
|
| 765 |
+
error_response = {
|
| 766 |
+
"error": {
|
| 767 |
+
"message": "请求被上游WAF拦截(405 Method Not Allowed),可能是请求头或签名异常,请稍后重试...",
|
| 768 |
+
"type": "waf_blocked",
|
| 769 |
+
"code": 405
|
| 770 |
+
}
|
| 771 |
+
}
|
| 772 |
+
else:
|
| 773 |
+
error_response = {
|
| 774 |
+
"error": {
|
| 775 |
+
"message": f"Upstream error: {response.status_code}",
|
| 776 |
+
"type": "upstream_error",
|
| 777 |
+
"code": response.status_code
|
| 778 |
+
}
|
| 779 |
}
|
|
|
|
| 780 |
yield f"data: {json.dumps(error_response)}\n\n"
|
| 781 |
yield "data: [DONE]\n\n"
|
| 782 |
return
|
|
|
|
| 937 |
|
| 938 |
# 尝试从缓冲区提取 tool_calls
|
| 939 |
tool_calls = None
|
|
|
|
| 940 |
|
| 941 |
if has_tools:
|
| 942 |
+
tool_calls, _ = parse_and_extract_tool_calls(buffered_content)
|
| 943 |
|
| 944 |
if tool_calls:
|
| 945 |
# 发现工具调用
|
|
|
|
| 986 |
yield "data: [DONE]\n\n"
|
| 987 |
|
| 988 |
else:
|
| 989 |
+
# 没有工具调用,流式内容已经在上面的增量输出中发送过了
|
| 990 |
+
# 这里只需要发送 finish 块即可,不要再次发送内容
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
if not has_sent_role and not has_thinking:
|
| 992 |
role_chunk = self.create_openai_chunk(
|
| 993 |
chat_id,
|
|
|
|
| 997 |
yield await self.format_sse_chunk(role_chunk)
|
| 998 |
has_sent_role = True
|
| 999 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
finish_chunk = self.create_openai_chunk(
|
| 1001 |
chat_id,
|
| 1002 |
model,
|
app/utils/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
#!/usr/bin/env python
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
-
from app.utils import
|
| 5 |
|
| 6 |
-
__all__ = ["
|
|
|
|
| 1 |
#!/usr/bin/env python
|
| 2 |
# -*- coding: utf-8 -*-
|
| 3 |
|
| 4 |
+
from app.utils import reload_config, logger
|
| 5 |
|
| 6 |
+
__all__ = ["reload_config", "logger"]
|
app/utils/signature.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
Z.AI 签名工具模块
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import hmac
|
| 9 |
+
import hashlib
|
| 10 |
+
import base64
|
| 11 |
+
from typing import Dict
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def generate_signature(e: str, t: str, s: int) -> dict:
|
| 15 |
+
"""Generate signature matching JavaScript zs function.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
e: canonical metadata string, e.g. "requestId,<uuid>,timestamp,<ms>,user_id,<id>"
|
| 19 |
+
t: latest user message text that feeds into the signature prompt (may be empty)
|
| 20 |
+
s: timestamp in milliseconds
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Dictionary with signature and timestamp
|
| 24 |
+
"""
|
| 25 |
+
# r = Number(s) - convert to number (already a number in Python)
|
| 26 |
+
r = s
|
| 27 |
+
# i = s - timestamp as string
|
| 28 |
+
i = str(s)
|
| 29 |
+
|
| 30 |
+
# n = new TextEncoder
|
| 31 |
+
# a = n.encode(t)
|
| 32 |
+
a = t.encode('utf-8')
|
| 33 |
+
|
| 34 |
+
# w = btoa(String.fromCharCode(...a))
|
| 35 |
+
# This is equivalent to base64 encoding the UTF-8 bytes
|
| 36 |
+
w = base64.b64encode(a).decode('ascii')
|
| 37 |
+
|
| 38 |
+
# c = `${e}|${w}|${i}`
|
| 39 |
+
c = f"{e}|{w}|{i}"
|
| 40 |
+
|
| 41 |
+
# E = Math.floor(r / (5 * 60 * 1e3))
|
| 42 |
+
E = r // (5 * 60 * 1000)
|
| 43 |
+
|
| 44 |
+
# A = CryptoJS.HmacSHA256(`${E}`, "key-@@@@)))()((9))-xxxx&&&%%%%%")
|
| 45 |
+
secret = "key-@@@@)))()((9))-xxxx&&&%%%%%"
|
| 46 |
+
A = hmac.new(secret.encode('utf-8'), str(E).encode('utf-8'), hashlib.sha256).hexdigest()
|
| 47 |
+
|
| 48 |
+
# k = CryptoJS.HmacSHA256(c, A).toString()
|
| 49 |
+
k = hmac.new(A.encode('utf-8'), c.encode('utf-8'), hashlib.sha256).hexdigest()
|
| 50 |
+
|
| 51 |
+
# return n.encode(c), { signature: k, timestamp: i }
|
| 52 |
+
# Note: n.encode(c) is not used in the return value, so we ignore it
|
| 53 |
+
return {
|
| 54 |
+
"signature": k,
|
| 55 |
+
"timestamp": i
|
| 56 |
+
}
|
app/utils/sse_tool_handler.py
DELETED
|
@@ -1,612 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python
|
| 2 |
-
# -*- coding: utf-8 -*-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
SSE Tool Handler
|
| 6 |
-
|
| 7 |
-
处理 Z.AI SSE 流数据并转换为 OpenAI 兼容格式的工具调用处理器。
|
| 8 |
-
|
| 9 |
-
主要功能:
|
| 10 |
-
- 解析 glm_block 格式的工具调用
|
| 11 |
-
- 从 metadata.arguments 提取完整参数
|
| 12 |
-
- 支持多阶段处理:thinking → tool_call → other → answer
|
| 13 |
-
- 输出符合 OpenAI API 规范的流式响应
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
import json
|
| 17 |
-
import time
|
| 18 |
-
from typing import Dict, Any, Generator
|
| 19 |
-
from enum import Enum
|
| 20 |
-
|
| 21 |
-
from app.utils.logger import get_logger
|
| 22 |
-
|
| 23 |
-
logger = get_logger()
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class SSEPhase(Enum):
|
| 27 |
-
"""SSE 处理阶段枚举"""
|
| 28 |
-
THINKING = "thinking"
|
| 29 |
-
TOOL_CALL = "tool_call"
|
| 30 |
-
OTHER = "other"
|
| 31 |
-
ANSWER = "answer"
|
| 32 |
-
DONE = "done"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class SSEToolHandler:
|
| 36 |
-
"""SSE 工具调用处理器"""
|
| 37 |
-
|
| 38 |
-
def __init__(self, model: str, stream: bool = True):
|
| 39 |
-
self.model = model
|
| 40 |
-
self.stream = stream
|
| 41 |
-
|
| 42 |
-
# 状态管理
|
| 43 |
-
self.current_phase = None
|
| 44 |
-
self.has_tool_call = False
|
| 45 |
-
|
| 46 |
-
# 工具调用状态
|
| 47 |
-
self.tool_id = ""
|
| 48 |
-
self.tool_name = ""
|
| 49 |
-
self.tool_args = ""
|
| 50 |
-
self.tool_call_usage = {}
|
| 51 |
-
self.content_index = 0 # 工具调用索引
|
| 52 |
-
|
| 53 |
-
# 性能优化:内容缓冲
|
| 54 |
-
self.content_buffer = ""
|
| 55 |
-
self.buffer_size = 0
|
| 56 |
-
self.last_flush_time = time.time()
|
| 57 |
-
self.flush_interval = 0.05 # 50ms 刷新间隔
|
| 58 |
-
self.max_buffer_size = 100 # 最大缓冲字符数
|
| 59 |
-
|
| 60 |
-
logger.debug(f"🔧 初始化工具处理器: model={model}, stream={stream}")
|
| 61 |
-
|
| 62 |
-
def process_sse_chunk(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
|
| 63 |
-
"""
|
| 64 |
-
处理 SSE 数据块,返回 OpenAI 格式的流式响应
|
| 65 |
-
|
| 66 |
-
Args:
|
| 67 |
-
chunk_data: Z.AI SSE 数据块
|
| 68 |
-
|
| 69 |
-
Yields:
|
| 70 |
-
str: OpenAI 格式的 SSE 响应行
|
| 71 |
-
"""
|
| 72 |
-
try:
|
| 73 |
-
phase = chunk_data.get("phase")
|
| 74 |
-
edit_content = chunk_data.get("edit_content", "")
|
| 75 |
-
delta_content = chunk_data.get("delta_content", "")
|
| 76 |
-
edit_index = chunk_data.get("edit_index")
|
| 77 |
-
usage = chunk_data.get("usage", {})
|
| 78 |
-
|
| 79 |
-
# 数据验证
|
| 80 |
-
if not phase:
|
| 81 |
-
logger.warning("⚠️ 收到无效的 SSE 块:缺少 phase 字段")
|
| 82 |
-
return
|
| 83 |
-
|
| 84 |
-
# 阶段变化检测和日志
|
| 85 |
-
if phase != self.current_phase:
|
| 86 |
-
# 阶段变化时强制刷新缓冲区
|
| 87 |
-
if hasattr(self, 'content_buffer') and self.content_buffer:
|
| 88 |
-
yield from self._flush_content_buffer()
|
| 89 |
-
|
| 90 |
-
logger.info(f"📈 SSE 阶段变化: {self.current_phase} → {phase}")
|
| 91 |
-
content_preview = edit_content or delta_content
|
| 92 |
-
if content_preview:
|
| 93 |
-
logger.debug(f" 📝 内容预览: {content_preview[:1000]}{'...' if len(content_preview) > 1000 else ''}")
|
| 94 |
-
if edit_index is not None:
|
| 95 |
-
logger.debug(f" 📍 edit_index: {edit_index}")
|
| 96 |
-
self.current_phase = phase
|
| 97 |
-
|
| 98 |
-
# 根据阶段处理
|
| 99 |
-
if phase == SSEPhase.THINKING.value:
|
| 100 |
-
yield from self._process_thinking_phase(delta_content)
|
| 101 |
-
|
| 102 |
-
elif phase == SSEPhase.TOOL_CALL.value:
|
| 103 |
-
yield from self._process_tool_call_phase(edit_content)
|
| 104 |
-
|
| 105 |
-
elif phase == SSEPhase.OTHER.value:
|
| 106 |
-
yield from self._process_other_phase(usage, edit_content)
|
| 107 |
-
|
| 108 |
-
elif phase == SSEPhase.ANSWER.value:
|
| 109 |
-
yield from self._process_answer_phase(delta_content)
|
| 110 |
-
|
| 111 |
-
elif phase == SSEPhase.DONE.value:
|
| 112 |
-
yield from self._process_done_phase(chunk_data)
|
| 113 |
-
else:
|
| 114 |
-
logger.warning(f"⚠️ 未知的 SSE 阶段: {phase}")
|
| 115 |
-
|
| 116 |
-
except Exception as e:
|
| 117 |
-
logger.error(f"❌ 处理 SSE 块时发生错误: {e}")
|
| 118 |
-
logger.debug(f" 📦 错误块数据: {chunk_data}")
|
| 119 |
-
# 不中断流,继续处理后续块
|
| 120 |
-
|
| 121 |
-
def _process_thinking_phase(self, delta_content: str) -> Generator[str, None, None]:
|
| 122 |
-
"""处理思考阶段"""
|
| 123 |
-
if not delta_content:
|
| 124 |
-
return
|
| 125 |
-
|
| 126 |
-
logger.debug(f"🤔 思考内容: +{len(delta_content)} 字符")
|
| 127 |
-
|
| 128 |
-
# 在流模式下输出思考内容
|
| 129 |
-
if self.stream:
|
| 130 |
-
chunk = self._create_content_chunk(delta_content)
|
| 131 |
-
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 132 |
-
|
| 133 |
-
def _process_tool_call_phase(self, edit_content: str) -> Generator[str, None, None]:
|
| 134 |
-
"""处理工具调用阶段"""
|
| 135 |
-
if not edit_content:
|
| 136 |
-
return
|
| 137 |
-
|
| 138 |
-
logger.debug(f"🔧 进入工具调用阶段,内容长度: {len(edit_content)}")
|
| 139 |
-
|
| 140 |
-
# 检测 glm_block 标记
|
| 141 |
-
if "<glm_block " in edit_content:
|
| 142 |
-
yield from self._handle_glm_blocks(edit_content)
|
| 143 |
-
else:
|
| 144 |
-
# 没有 glm_block 标记,可能是参数补充
|
| 145 |
-
if self.has_tool_call:
|
| 146 |
-
# 只累积���数部分,找到第一个 ", "result"" 之前的内容
|
| 147 |
-
result_pos = edit_content.find('", "result"')
|
| 148 |
-
if result_pos > 0:
|
| 149 |
-
param_fragment = edit_content[:result_pos]
|
| 150 |
-
self.tool_args += param_fragment
|
| 151 |
-
logger.debug(f"📦 累积参数片段: {param_fragment}")
|
| 152 |
-
else:
|
| 153 |
-
# 如果没有找到结束标记,累积整个内容(可能是中间片段)
|
| 154 |
-
self.tool_args += edit_content
|
| 155 |
-
logger.debug(f"📦 累积参数片段: {edit_content[:100]}...")
|
| 156 |
-
|
| 157 |
-
def _handle_glm_blocks(self, edit_content: str) -> Generator[str, None, None]:
|
| 158 |
-
"""处理 glm_block 标记的内容"""
|
| 159 |
-
blocks = edit_content.split('<glm_block ')
|
| 160 |
-
logger.debug(f"📦 分割得到 {len(blocks)} 个块")
|
| 161 |
-
|
| 162 |
-
for index, block in enumerate(blocks):
|
| 163 |
-
if not block.strip():
|
| 164 |
-
continue
|
| 165 |
-
|
| 166 |
-
if index == 0:
|
| 167 |
-
# 第一个块:提取参数片段
|
| 168 |
-
if self.has_tool_call:
|
| 169 |
-
logger.debug(f"📦 从第一个块提取参数片段")
|
| 170 |
-
# 找到 "result" 的位置,提取之前的参数片段
|
| 171 |
-
result_pos = edit_content.find('"result"')
|
| 172 |
-
if result_pos > 0:
|
| 173 |
-
# 往前退3个字符去掉 ", "
|
| 174 |
-
param_fragment = edit_content[:result_pos - 3]
|
| 175 |
-
self.tool_args += param_fragment
|
| 176 |
-
logger.debug(f"📦 累积参数片段: {param_fragment}")
|
| 177 |
-
else:
|
| 178 |
-
# 没有活跃工具调用,跳过第一个块
|
| 179 |
-
continue
|
| 180 |
-
else:
|
| 181 |
-
# 后续块:处理新工具调用
|
| 182 |
-
if "</glm_block>" not in block:
|
| 183 |
-
continue
|
| 184 |
-
|
| 185 |
-
# 如果有活跃的工具调用,先完成它
|
| 186 |
-
if self.has_tool_call:
|
| 187 |
-
# 补全参数并完成工具调用
|
| 188 |
-
self.tool_args += '"' # 补全最后的引号
|
| 189 |
-
yield from self._finish_current_tool()
|
| 190 |
-
|
| 191 |
-
# 处理新工具调用
|
| 192 |
-
yield from self._process_metadata_block(block)
|
| 193 |
-
|
| 194 |
-
def _process_metadata_block(self, block: str) -> Generator[str, None, None]:
|
| 195 |
-
"""处理包含工具元数据的块"""
|
| 196 |
-
try:
|
| 197 |
-
# 提取 JSON 内容
|
| 198 |
-
start_pos = block.find('>')
|
| 199 |
-
end_pos = block.rfind('</glm_block>')
|
| 200 |
-
|
| 201 |
-
if start_pos == -1 or end_pos == -1:
|
| 202 |
-
logger.warning(f"❌ 无法找到 JSON 内容边界: {block[:1000]}...")
|
| 203 |
-
return
|
| 204 |
-
|
| 205 |
-
json_content = block[start_pos + 1:end_pos]
|
| 206 |
-
logger.debug(f"📦 提取的 JSON 内容: {json_content[:1000]}...")
|
| 207 |
-
|
| 208 |
-
# 解析工具元数据
|
| 209 |
-
metadata_obj = json.loads(json_content)
|
| 210 |
-
|
| 211 |
-
if "data" in metadata_obj and "metadata" in metadata_obj["data"]:
|
| 212 |
-
metadata = metadata_obj["data"]["metadata"]
|
| 213 |
-
|
| 214 |
-
# 开始新的工具调用
|
| 215 |
-
self.tool_id = metadata.get("id", f"call_{int(time.time() * 1000000)}")
|
| 216 |
-
self.tool_name = metadata.get("name", "unknown")
|
| 217 |
-
self.has_tool_call = True
|
| 218 |
-
|
| 219 |
-
# 只有在这是第二个及以后的工具调用时才递增 index
|
| 220 |
-
# 第一个工具调用应该使用 index 0
|
| 221 |
-
|
| 222 |
-
# 从 metadata.arguments 获取参数起始部分
|
| 223 |
-
if "arguments" in metadata:
|
| 224 |
-
arguments_str = metadata["arguments"]
|
| 225 |
-
# 去掉最后一个字符
|
| 226 |
-
self.tool_args = arguments_str[:-1] if arguments_str.endswith('"') else arguments_str
|
| 227 |
-
logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 初始参数: {self.tool_args}")
|
| 228 |
-
else:
|
| 229 |
-
self.tool_args = "{}"
|
| 230 |
-
logger.debug(f"🎯 新工具调用: {self.tool_name}(id={self.tool_id}), 空参数")
|
| 231 |
-
|
| 232 |
-
except (json.JSONDecodeError, KeyError, AttributeError) as e:
|
| 233 |
-
logger.error(f"❌ 解析工具元数据失败: {e}, 块内容: {block[:1000]}...")
|
| 234 |
-
|
| 235 |
-
# 确保返回生成器(即使为空)
|
| 236 |
-
if False: # 永远不会执行,但确保函数是生成器
|
| 237 |
-
yield
|
| 238 |
-
|
| 239 |
-
def _process_other_phase(self, usage: Dict[str, Any], edit_content: str = "") -> Generator[str, None, None]:
|
| 240 |
-
"""处理其他阶段"""
|
| 241 |
-
# 保存使用统计信息
|
| 242 |
-
if usage:
|
| 243 |
-
self.tool_call_usage = usage
|
| 244 |
-
logger.debug(f"📊 保存使用统计: {usage}")
|
| 245 |
-
|
| 246 |
-
# 工具调用完成判断:检测到 "null," 开头的 edit_content
|
| 247 |
-
if self.has_tool_call and edit_content and edit_content.startswith("null,"):
|
| 248 |
-
logger.info(f"🏁 检测到工具调用结束标记")
|
| 249 |
-
|
| 250 |
-
# 完成当前工具调用
|
| 251 |
-
yield from self._finish_current_tool()
|
| 252 |
-
|
| 253 |
-
# 发��流结束标记
|
| 254 |
-
if self.stream:
|
| 255 |
-
yield "data: [DONE]\n\n"
|
| 256 |
-
|
| 257 |
-
# 重置状态
|
| 258 |
-
self._reset_all_state()
|
| 259 |
-
|
| 260 |
-
def _process_answer_phase(self, delta_content: str) -> Generator[str, None, None]:
|
| 261 |
-
"""处理回答阶段(优化版本)"""
|
| 262 |
-
if not delta_content:
|
| 263 |
-
return
|
| 264 |
-
|
| 265 |
-
logger.info(f"📝 工具处理器收到答案内容: {delta_content[:50]}...")
|
| 266 |
-
|
| 267 |
-
# 添加到缓冲区
|
| 268 |
-
self.content_buffer += delta_content
|
| 269 |
-
self.buffer_size += len(delta_content)
|
| 270 |
-
|
| 271 |
-
current_time = time.time()
|
| 272 |
-
time_since_last_flush = current_time - self.last_flush_time
|
| 273 |
-
|
| 274 |
-
# 检查是否需要刷新缓冲区
|
| 275 |
-
should_flush = (
|
| 276 |
-
self.buffer_size >= self.max_buffer_size or # 缓冲区满了
|
| 277 |
-
time_since_last_flush >= self.flush_interval or # 时间间隔到了
|
| 278 |
-
'\n' in delta_content or # 包含换行符
|
| 279 |
-
'。' in delta_content or '!' in delta_content or '?' in delta_content # 包含句子结束符
|
| 280 |
-
)
|
| 281 |
-
|
| 282 |
-
if should_flush and self.content_buffer:
|
| 283 |
-
yield from self._flush_content_buffer()
|
| 284 |
-
|
| 285 |
-
def _flush_content_buffer(self) -> Generator[str, None, None]:
|
| 286 |
-
"""刷新内容缓冲区"""
|
| 287 |
-
if not self.content_buffer:
|
| 288 |
-
return
|
| 289 |
-
|
| 290 |
-
logger.info(f"💬 工具处理器刷新缓冲区: {self.buffer_size} 字符 - {self.content_buffer[:50]}...")
|
| 291 |
-
|
| 292 |
-
if self.stream:
|
| 293 |
-
chunk = self._create_content_chunk(self.content_buffer)
|
| 294 |
-
output_data = f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
|
| 295 |
-
logger.info(f"➡️ 工具处理器输出: {output_data[:100]}...")
|
| 296 |
-
yield output_data
|
| 297 |
-
|
| 298 |
-
# 清空缓冲区
|
| 299 |
-
self.content_buffer = ""
|
| 300 |
-
self.buffer_size = 0
|
| 301 |
-
self.last_flush_time = time.time()
|
| 302 |
-
|
| 303 |
-
def _process_done_phase(self, chunk_data: Dict[str, Any]) -> Generator[str, None, None]:
|
| 304 |
-
"""处理完成阶段"""
|
| 305 |
-
logger.info("🏁 对话完成")
|
| 306 |
-
|
| 307 |
-
# 先刷新任何剩余的缓冲内容
|
| 308 |
-
if self.content_buffer:
|
| 309 |
-
yield from self._flush_content_buffer()
|
| 310 |
-
|
| 311 |
-
# 完成任何未完成的工具调用
|
| 312 |
-
if self.has_tool_call:
|
| 313 |
-
yield from self._finish_current_tool()
|
| 314 |
-
|
| 315 |
-
# 发送流结束标记
|
| 316 |
-
if self.stream:
|
| 317 |
-
# 创建最终的完成块
|
| 318 |
-
final_chunk = {
|
| 319 |
-
"id": f"chatcmpl-{int(time.time())}",
|
| 320 |
-
"object": "chat.completion.chunk",
|
| 321 |
-
"created": int(time.time()),
|
| 322 |
-
"model": self.model,
|
| 323 |
-
"choices": [{
|
| 324 |
-
"index": 0,
|
| 325 |
-
"delta": {},
|
| 326 |
-
"finish_reason": "stop"
|
| 327 |
-
}]
|
| 328 |
-
}
|
| 329 |
-
|
| 330 |
-
# 如果有 usage 信息,添加到最终块中
|
| 331 |
-
if "usage" in chunk_data:
|
| 332 |
-
final_chunk["usage"] = chunk_data["usage"]
|
| 333 |
-
|
| 334 |
-
yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n"
|
| 335 |
-
yield "data: [DONE]\n\n"
|
| 336 |
-
|
| 337 |
-
# 重置所有状态
|
| 338 |
-
self._reset_all_state()
|
| 339 |
-
|
| 340 |
-
def _finish_current_tool(self) -> Generator[str, None, None]:
|
| 341 |
-
"""完成当前工具调用"""
|
| 342 |
-
if not self.has_tool_call:
|
| 343 |
-
return
|
| 344 |
-
|
| 345 |
-
# 修复参数格式
|
| 346 |
-
fixed_args = self._fix_tool_arguments(self.tool_args)
|
| 347 |
-
logger.debug(f"✅ 完成工具调用: {self.tool_name}, 参数: {fixed_args}")
|
| 348 |
-
|
| 349 |
-
# 输出工具调用(开始 + 参数 + 完成)
|
| 350 |
-
if self.stream:
|
| 351 |
-
# 发送工具开始块
|
| 352 |
-
start_chunk = self._create_tool_start_chunk()
|
| 353 |
-
yield f"data: {json.dumps(start_chunk, ensure_ascii=False)}\n\n"
|
| 354 |
-
|
| 355 |
-
# 发送参数块
|
| 356 |
-
args_chunk = self._create_tool_arguments_chunk(fixed_args)
|
| 357 |
-
yield f"data: {json.dumps(args_chunk, ensure_ascii=False)}\n\n"
|
| 358 |
-
|
| 359 |
-
# 发送完成块
|
| 360 |
-
finish_chunk = self._create_tool_finish_chunk()
|
| 361 |
-
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
| 362 |
-
|
| 363 |
-
# 重置工具状态
|
| 364 |
-
self._reset_tool_state()
|
| 365 |
-
|
| 366 |
-
def _fix_tool_arguments(self, raw_args: str) -> str:
|
| 367 |
-
"""使用 json-repair 库修复工具参数格式"""
|
| 368 |
-
if not raw_args or raw_args == "{}":
|
| 369 |
-
return "{}"
|
| 370 |
-
|
| 371 |
-
logger.debug(f"🔧 开始修复参数: {raw_args[:1000]}{'...' if len(raw_args) > 1000 else ''}")
|
| 372 |
-
|
| 373 |
-
# 统一的修复流程:预处理 -> json-repair -> 后处理
|
| 374 |
-
try:
|
| 375 |
-
# 1. 预处理:只处理 json-repair 无法处理的问题
|
| 376 |
-
processed_args = self._preprocess_json_string(raw_args.strip())
|
| 377 |
-
|
| 378 |
-
# 2. 使用 json-repair 进行主要修复
|
| 379 |
-
from json_repair import repair_json
|
| 380 |
-
repaired_json = repair_json(processed_args)
|
| 381 |
-
logger.debug(f"🔧 json-repair 修复结果: {repaired_json}")
|
| 382 |
-
|
| 383 |
-
# 3. 解析并后处理
|
| 384 |
-
args_obj = json.loads(repaired_json)
|
| 385 |
-
args_obj = self._post_process_args(args_obj)
|
| 386 |
-
|
| 387 |
-
# 4. 生成最终结果
|
| 388 |
-
fixed_result = json.dumps(args_obj, ensure_ascii=False)
|
| 389 |
-
|
| 390 |
-
return fixed_result
|
| 391 |
-
|
| 392 |
-
except Exception as e:
|
| 393 |
-
logger.error(f"❌ JSON 修复失败: {e}, 原始参数: {raw_args[:1000]}..., 使用空参数")
|
| 394 |
-
return "{}"
|
| 395 |
-
|
| 396 |
-
def _post_process_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 397 |
-
"""统一的后处理方法"""
|
| 398 |
-
# 修复路径中的过度转义
|
| 399 |
-
args_obj = self._fix_path_escaping_in_args(args_obj)
|
| 400 |
-
|
| 401 |
-
# 修复命令中的多余引号
|
| 402 |
-
args_obj = self._fix_command_quotes(args_obj)
|
| 403 |
-
|
| 404 |
-
return args_obj
|
| 405 |
-
|
| 406 |
-
def _preprocess_json_string(self, text: str) -> str:
|
| 407 |
-
"""预处理 JSON 字符串,只处理 json-repair 无法处理的问题"""
|
| 408 |
-
import re
|
| 409 |
-
|
| 410 |
-
# 只保留 json-repair 无法处理的预处理步骤
|
| 411 |
-
|
| 412 |
-
# 1. 修复缺少开始括号的情况(json-repair 无法处理)
|
| 413 |
-
if not text.startswith('{') and text.endswith('}'):
|
| 414 |
-
text = '{' + text
|
| 415 |
-
logger.debug(f"🔧 补全开始括号")
|
| 416 |
-
|
| 417 |
-
# 2. 修复末尾多余的反斜杠和引号(json-repair 可能处理不当)
|
| 418 |
-
# 匹配模式:字符串值末尾的 \" 后面跟着 } 或 ,
|
| 419 |
-
# 例如:{"url":"https://www.bilibili.com\"} -> {"url":"https://www.bilibili.com"}
|
| 420 |
-
# 例如:{"url":"https://www.bilibili.com\",} -> {"url":"https://www.bilibili.com",}
|
| 421 |
-
pattern = r'([^\\])\\"([}\s,])'
|
| 422 |
-
if re.search(pattern, text):
|
| 423 |
-
text = re.sub(pattern, r'\1"\2', text)
|
| 424 |
-
logger.debug(f"🔧 修复末尾多余的反斜杠")
|
| 425 |
-
|
| 426 |
-
return text
|
| 427 |
-
|
| 428 |
-
def _fix_path_escaping_in_args(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 429 |
-
"""修复参数对象中路径的过度转义问题"""
|
| 430 |
-
import re
|
| 431 |
-
|
| 432 |
-
# 需要检查的路径字段
|
| 433 |
-
path_fields = ['file_path', 'path', 'directory', 'folder']
|
| 434 |
-
|
| 435 |
-
for field in path_fields:
|
| 436 |
-
if field in args_obj and isinstance(args_obj[field], str):
|
| 437 |
-
path_value = args_obj[field]
|
| 438 |
-
|
| 439 |
-
# 检查是否是Windows路径且包含过度转义
|
| 440 |
-
if path_value.startswith('C:') and '\\\\' in path_value:
|
| 441 |
-
logger.debug(f"🔍 检查路径字段 {field}: {repr(path_value)}")
|
| 442 |
-
|
| 443 |
-
# 分析路径结构:正常路径应该是 C:\Users\...
|
| 444 |
-
# 但过度转义的路径可能是 C:\Users\\Documents(多了一个反斜杠)
|
| 445 |
-
# 我们需要找到不正常的双反斜杠模式并修复
|
| 446 |
-
|
| 447 |
-
# 先检查是否有不正常的双反斜杠(不在路径开头)
|
| 448 |
-
# 正常:C:\Users\Documents
|
| 449 |
-
# 异常:C:\Users\\Documents 或 C:\Users\\\\Documents
|
| 450 |
-
|
| 451 |
-
# 使用更精确的模式:匹配路径分隔符后的额外反斜杠
|
| 452 |
-
# 但要保留正常的路径分隔符
|
| 453 |
-
fixed_path = path_value
|
| 454 |
-
|
| 455 |
-
# 检查是否有连续的多个反斜杠(超过正常的路径分隔符)
|
| 456 |
-
if '\\\\' in path_value:
|
| 457 |
-
# 计算反斜杠的数量,如果超过正常数量就修复
|
| 458 |
-
parts = path_value.split('\\')
|
| 459 |
-
# 重新组装路径,去除空的部分(由多余的反斜杠造成)
|
| 460 |
-
clean_parts = [part for part in parts if part]
|
| 461 |
-
if len(clean_parts) > 1:
|
| 462 |
-
fixed_path = '\\'.join(clean_parts)
|
| 463 |
-
|
| 464 |
-
logger.debug(f"🔍 修复后路径: {repr(fixed_path)}")
|
| 465 |
-
|
| 466 |
-
if fixed_path != path_value:
|
| 467 |
-
args_obj[field] = fixed_path
|
| 468 |
-
logger.debug(f"🔧 修复字段 {field} 的路径转义: {path_value} -> {fixed_path}")
|
| 469 |
-
else:
|
| 470 |
-
logger.debug(f"🔍 路径无需修复: {path_value}")
|
| 471 |
-
|
| 472 |
-
return args_obj
|
| 473 |
-
|
| 474 |
-
def _fix_command_quotes(self, args_obj: Dict[str, Any]) -> Dict[str, Any]:
|
| 475 |
-
"""修复命令中的多余引号问题"""
|
| 476 |
-
import re
|
| 477 |
-
|
| 478 |
-
# 检查命令字段
|
| 479 |
-
if 'command' in args_obj and isinstance(args_obj['command'], str):
|
| 480 |
-
command = args_obj['command']
|
| 481 |
-
|
| 482 |
-
# 检查是否以双引号结尾(多余的引号)
|
| 483 |
-
if command.endswith('""'):
|
| 484 |
-
logger.debug(f"🔧 发现命令末尾多余引号: {command}")
|
| 485 |
-
# 移除最后一个多余的引号
|
| 486 |
-
fixed_command = command[:-1]
|
| 487 |
-
args_obj['command'] = fixed_command
|
| 488 |
-
logger.debug(f"🔧 修复命令引号: {command} -> {fixed_command}")
|
| 489 |
-
|
| 490 |
-
# 检查其他可能的引号问题
|
| 491 |
-
# 例如:路径末尾的 \"" 模式
|
| 492 |
-
elif re.search(r'\\""+$', command):
|
| 493 |
-
logger.debug(f"🔧 发现命令末尾引号模式问题: {command}")
|
| 494 |
-
# 修复路径末尾的引号问题
|
| 495 |
-
fixed_command = re.sub(r'\\""+$', '\\"', command)
|
| 496 |
-
args_obj['command'] = fixed_command
|
| 497 |
-
logger.debug(f"🔧 修复命令引号模式: {command} -> {fixed_command}")
|
| 498 |
-
|
| 499 |
-
return args_obj
|
| 500 |
-
|
| 501 |
-
def _create_content_chunk(self, content: str) -> Dict[str, Any]:
|
| 502 |
-
"""创建内容块"""
|
| 503 |
-
return {
|
| 504 |
-
"id": f"chatcmpl-{int(time.time())}",
|
| 505 |
-
"object": "chat.completion.chunk",
|
| 506 |
-
"created": int(time.time()),
|
| 507 |
-
"model": self.model,
|
| 508 |
-
"choices": [{
|
| 509 |
-
"index": 0,
|
| 510 |
-
"delta": {
|
| 511 |
-
"role": "assistant",
|
| 512 |
-
"content": content
|
| 513 |
-
},
|
| 514 |
-
"finish_reason": None
|
| 515 |
-
}]
|
| 516 |
-
}
|
| 517 |
-
|
| 518 |
-
def _create_tool_start_chunk(self) -> Dict[str, Any]:
|
| 519 |
-
"""创建工具开始块"""
|
| 520 |
-
return {
|
| 521 |
-
"id": f"chatcmpl-{int(time.time())}",
|
| 522 |
-
"object": "chat.completion.chunk",
|
| 523 |
-
"created": int(time.time()),
|
| 524 |
-
"model": self.model,
|
| 525 |
-
"choices": [{
|
| 526 |
-
"index": 0,
|
| 527 |
-
"delta": {
|
| 528 |
-
"role": "assistant",
|
| 529 |
-
"tool_calls": [{
|
| 530 |
-
"index": self.content_index,
|
| 531 |
-
"id": self.tool_id,
|
| 532 |
-
"type": "function",
|
| 533 |
-
"function": {
|
| 534 |
-
"name": self.tool_name,
|
| 535 |
-
"arguments": ""
|
| 536 |
-
}
|
| 537 |
-
}]
|
| 538 |
-
},
|
| 539 |
-
"finish_reason": None
|
| 540 |
-
}]
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
def _create_tool_arguments_chunk(self, arguments: str) -> Dict[str, Any]:
|
| 544 |
-
"""创建工具参数块"""
|
| 545 |
-
return {
|
| 546 |
-
"id": f"chatcmpl-{int(time.time())}",
|
| 547 |
-
"object": "chat.completion.chunk",
|
| 548 |
-
"created": int(time.time()),
|
| 549 |
-
"model": self.model,
|
| 550 |
-
"choices": [{
|
| 551 |
-
"index": 0,
|
| 552 |
-
"delta": {
|
| 553 |
-
"tool_calls": [{
|
| 554 |
-
"index": self.content_index,
|
| 555 |
-
"id": self.tool_id,
|
| 556 |
-
"function": {
|
| 557 |
-
"arguments": arguments
|
| 558 |
-
}
|
| 559 |
-
}]
|
| 560 |
-
},
|
| 561 |
-
"finish_reason": None
|
| 562 |
-
}]
|
| 563 |
-
}
|
| 564 |
-
|
| 565 |
-
def _create_tool_finish_chunk(self) -> Dict[str, Any]:
|
| 566 |
-
"""创建工具完成块"""
|
| 567 |
-
chunk = {
|
| 568 |
-
"id": f"chatcmpl-{int(time.time())}",
|
| 569 |
-
"object": "chat.completion.chunk",
|
| 570 |
-
"created": int(time.time()),
|
| 571 |
-
"model": self.model,
|
| 572 |
-
"choices": [{
|
| 573 |
-
"index": 0,
|
| 574 |
-
"delta": {
|
| 575 |
-
"tool_calls": []
|
| 576 |
-
},
|
| 577 |
-
"finish_reason": "tool_calls"
|
| 578 |
-
}]
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
# 添加使用统计(如果有)
|
| 582 |
-
if self.tool_call_usage:
|
| 583 |
-
chunk["usage"] = self.tool_call_usage
|
| 584 |
-
|
| 585 |
-
return chunk
|
| 586 |
-
|
| 587 |
-
def _reset_tool_state(self):
|
| 588 |
-
"""重置工具状态"""
|
| 589 |
-
self.tool_id = ""
|
| 590 |
-
self.tool_name = ""
|
| 591 |
-
self.tool_args = ""
|
| 592 |
-
self.has_tool_call = False
|
| 593 |
-
# content_index 在单次对话中应该保持不变,只有在新的工具调用开始时才递增
|
| 594 |
-
|
| 595 |
-
def _reset_all_state(self):
|
| 596 |
-
"""重置所有状态"""
|
| 597 |
-
# 先刷新任何剩余的缓冲内容
|
| 598 |
-
if hasattr(self, 'content_buffer') and self.content_buffer:
|
| 599 |
-
list(self._flush_content_buffer()) # 消费生成器
|
| 600 |
-
|
| 601 |
-
self._reset_tool_state()
|
| 602 |
-
self.current_phase = None
|
| 603 |
-
self.tool_call_usage = {}
|
| 604 |
-
|
| 605 |
-
# 重置缓冲区
|
| 606 |
-
self.content_buffer = ""
|
| 607 |
-
self.buffer_size = 0
|
| 608 |
-
self.last_flush_time = time.time()
|
| 609 |
-
|
| 610 |
-
# content_index 重置为 0,为下一轮对话做准备
|
| 611 |
-
self.content_index = 0
|
| 612 |
-
logger.debug("🔄 重置所有处理器状态")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/.env.example
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
# ==============================================
|
| 2 |
-
# Z.AI API Server - Docker 环境变量配置示例
|
| 3 |
-
# ==============================================
|
| 4 |
-
|
| 5 |
-
# 管理后台密码
|
| 6 |
-
ADMIN_PASSWORD=admin123
|
| 7 |
-
|
| 8 |
-
# API 认证密钥 (用于验证客户端请求)
|
| 9 |
-
AUTH_TOKEN=sk-your-api-key-here
|
| 10 |
-
|
| 11 |
-
# 是否跳过 API Key 验证 (开发环境可设为 true)
|
| 12 |
-
SKIP_AUTH_TOKEN=false
|
| 13 |
-
|
| 14 |
-
# 调试日志 (生产环境建议设为 false)
|
| 15 |
-
DEBUG_LOGGING=true
|
| 16 |
-
|
| 17 |
-
# 匿名模式 (允许无 token 访问,需要配合 SKIP_AUTH_TOKEN=true)
|
| 18 |
-
ANONYMOUS_MODE=false
|
| 19 |
-
|
| 20 |
-
# Function Call 功能开关 (是否支持工具调用)
|
| 21 |
-
TOOL_SUPPORT=true
|
| 22 |
-
|
| 23 |
-
# 工具调用扫描限制 (字符数)
|
| 24 |
-
SCAN_LIMIT=200000
|
| 25 |
-
|
| 26 |
-
# 数据库路径 (Docker 环境使用持久化卷)
|
| 27 |
-
DB_PATH=/app/data/tokens.db
|
| 28 |
-
|
| 29 |
-
# Token 池配置
|
| 30 |
-
TOKEN_FAILURE_THRESHOLD=3
|
| 31 |
-
TOKEN_RECOVERY_TIMEOUT=300
|
| 32 |
-
|
| 33 |
-
# 服务配置
|
| 34 |
-
SERVICE_NAME=Z.AI_API_Server
|
| 35 |
-
LISTEN_PORT=7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/Dockerfile
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
FROM python:3.12-slim
|
| 2 |
-
|
| 3 |
-
# Set working directory
|
| 4 |
-
WORKDIR /app
|
| 5 |
-
|
| 6 |
-
# Create data and logs directories with proper permissions
|
| 7 |
-
RUN mkdir -p /app/data /app/logs && \
|
| 8 |
-
chmod 755 /app/data /app/logs
|
| 9 |
-
|
| 10 |
-
# Install dependencies
|
| 11 |
-
COPY requirements.txt .
|
| 12 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
-
|
| 14 |
-
# Copy application code
|
| 15 |
-
COPY . .
|
| 16 |
-
|
| 17 |
-
# Set environment variable for database path
|
| 18 |
-
ENV DB_PATH=/app/data/tokens.db
|
| 19 |
-
|
| 20 |
-
# Expose port
|
| 21 |
-
EXPOSE 7860
|
| 22 |
-
|
| 23 |
-
# Run the application
|
| 24 |
-
CMD ["python", "main.py"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy/docker-compose.yml → docker-compose.yml
RENAMED
|
File without changes
|
deploy/nginx.conf.example → nginx.conf.example
RENAMED
|
File without changes
|
tests/test_signature.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# 添加项目根目录到 Python 路径
|
| 6 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
+
|
| 8 |
+
from app.utils.signature import generate_signature
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
# 示例用法
|
| 13 |
+
e_value = "requestId,eef12d6c-6dc9-47a0-aae8-b9f3454f98c5,timestamp,1761038714733,user_id,21ea9ec3-e492-4dbb-b522-fc0eaf64f0f6"
|
| 14 |
+
t_value = "hi"
|
| 15 |
+
# r_value = int(time.time() * 1000)
|
| 16 |
+
r_value = 1761038714733
|
| 17 |
+
result = generate_signature(e_value, t_value, r_value)
|
| 18 |
+
print(f"生成的签名: {result['signature']}")
|
| 19 |
+
print(f"时间戳: {result['timestamp']}")
|
tests/test_simple_signature.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
简单测试签名工具
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# 添加项目根目录到 Python 路径
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 13 |
+
|
| 14 |
+
# 直接导入签名模块,避免导入整个应用
|
| 15 |
+
import importlib.util
|
| 16 |
+
spec = importlib.util.spec_from_file_location("signature", os.path.join(os.path.dirname(os.path.dirname(__file__)), "app/utils/signature.py"))
|
| 17 |
+
signature_module = importlib.util.module_from_spec(spec)
|
| 18 |
+
spec.loader.exec_module(signature_module)
|
| 19 |
+
|
| 20 |
+
generate_signature = signature_module.generate_signature
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
# 示例用法
|
| 25 |
+
e_value = "requestId,eef12d6c-6dc9-47a0-aae8-b9f3454f98c5,timestamp,1761038714733,user_id,21ea9ec3-e492-4dbb-b522-fc0eaf64f0f6"
|
| 26 |
+
t_value = "hi"
|
| 27 |
+
r_value = 1761038714733
|
| 28 |
+
result = generate_signature(e_value, t_value, r_value)
|
| 29 |
+
print(f"生成的签名: {result['signature']}")
|
| 30 |
+
print(f"时间戳: {result['timestamp']}")
|
| 31 |
+
|
| 32 |
+
# 验证函数是否正常工作
|
| 33 |
+
assert "signature" in result
|
| 34 |
+
assert "timestamp" in result
|
| 35 |
+
assert result["timestamp"] == str(r_value)
|
| 36 |
+
print("签名函数测试通过!")
|