Upload 5 files
Browse files- Dockerfile +42 -0
- README.md +5 -6
- entrypoint.sh +8 -0
- main.py +773 -0
- requirements.txt +3 -0
Dockerfile
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Base image
|
2 |
+
FROM python:3.9-slim
|
3 |
+
|
4 |
+
# Set up a new user named "user" with user ID 1000
|
5 |
+
RUN useradd -m -u 1000 user
|
6 |
+
|
7 |
+
# Install required system dependencies
|
8 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
9 |
+
build-essential \
|
10 |
+
&& apt-get clean \
|
11 |
+
&& rm -rf /var/lib/apt/lists/*
|
12 |
+
|
13 |
+
# Switch to the "user" user
|
14 |
+
USER user
|
15 |
+
|
16 |
+
# Set home to the user's home directory
|
17 |
+
ENV HOME=/home/user \
|
18 |
+
PATH=/home/user/.local/bin:$PATH
|
19 |
+
|
20 |
+
# Set the working directory
|
21 |
+
WORKDIR $HOME/app
|
22 |
+
|
23 |
+
# Copy requirements file
|
24 |
+
COPY --chown=user requirements.txt .
|
25 |
+
|
26 |
+
# Install dependencies
|
27 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
28 |
+
pip install --no-cache-dir -r requirements.txt
|
29 |
+
|
30 |
+
# Copy the application code
|
31 |
+
COPY --chown=user . .
|
32 |
+
|
33 |
+
# Make entrypoint script executable
|
34 |
+
USER root
|
35 |
+
RUN chmod +x entrypoint.sh
|
36 |
+
USER user
|
37 |
+
|
38 |
+
# Expose the port the app runs on
|
39 |
+
EXPOSE 7860
|
40 |
+
|
41 |
+
# Command to run the application
|
42 |
+
CMD ["./entrypoint.sh"]
|
README.md
CHANGED
@@ -1,11 +1,10 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
|
|
7 |
pinned: false
|
8 |
-
short_description: dify
|
9 |
---
|
10 |
|
11 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: OpenDify
|
3 |
+
emoji: 🤖
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: green
|
6 |
sdk: docker
|
7 |
+
app_port: 7860
|
8 |
pinned: false
|
|
|
9 |
---
|
10 |
|
|
entrypoint.sh
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/bin/bash
|
2 |
+
|
3 |
+
# 输出环境信息
|
4 |
+
echo "Starting OpenDify on Hugging Face Spaces"
|
5 |
+
echo "Python version: $(python --version)"
|
6 |
+
|
7 |
+
# 运行应用
|
8 |
+
exec python main.py
|
main.py
ADDED
@@ -0,0 +1,773 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import logging
|
3 |
+
import asyncio
|
4 |
+
from flask import Flask, request, Response, stream_with_context, jsonify
|
5 |
+
import httpx
|
6 |
+
import time
|
7 |
+
from dotenv import load_dotenv
|
8 |
+
import os
|
9 |
+
import ast
|
10 |
+
|
11 |
+
# 配置日志
|
12 |
+
logging.basicConfig(
|
13 |
+
level=logging.INFO,
|
14 |
+
format='%(asctime)s - %(levelname)s - %(message)s'
|
15 |
+
)
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
|
18 |
+
# 设置httpx的日志级别为WARNING,减少不必要的输出
|
19 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
20 |
+
|
21 |
+
# 加载环境变量
|
22 |
+
load_dotenv()
|
23 |
+
|
24 |
+
# 从环境变量读取有效的API密钥(逗号分隔)
|
25 |
+
VALID_API_KEYS = [key.strip() for key in os.getenv("VALID_API_KEYS", "").split(",") if key]
|
26 |
+
|
27 |
+
# 获取会话记忆功能模式配置
|
28 |
+
# 1: 构造history_message附加到消息中的模式(默认)
|
29 |
+
# 2: 零宽字符模式
|
30 |
+
CONVERSATION_MEMORY_MODE = int(os.getenv('CONVERSATION_MEMORY_MODE', '1'))
|
31 |
+
|
32 |
+
class DifyModelManager:
|
33 |
+
def __init__(self):
|
34 |
+
self.api_keys = []
|
35 |
+
self.name_to_api_key = {} # 应用名称到API Key的映射
|
36 |
+
self.api_key_to_name = {} # API Key到应用名称的映射
|
37 |
+
self.load_api_keys()
|
38 |
+
|
39 |
+
def load_api_keys(self):
|
40 |
+
"""从环境变量加载API Keys"""
|
41 |
+
api_keys_str = os.getenv('DIFY_API_KEYS', '')
|
42 |
+
if api_keys_str:
|
43 |
+
self.api_keys = [key.strip() for key in api_keys_str.split(',') if key.strip()]
|
44 |
+
logger.info(f"Loaded {len(self.api_keys)} API keys")
|
45 |
+
|
46 |
+
async def fetch_app_info(self, api_key):
|
47 |
+
"""获取Dify应用信息"""
|
48 |
+
try:
|
49 |
+
async with httpx.AsyncClient() as client:
|
50 |
+
headers = {
|
51 |
+
"Authorization": f"Bearer {api_key}",
|
52 |
+
"Content-Type": "application/json"
|
53 |
+
}
|
54 |
+
response = await client.get(
|
55 |
+
f"{DIFY_API_BASE}/info",
|
56 |
+
headers=headers,
|
57 |
+
params={"user": "default_user"}
|
58 |
+
)
|
59 |
+
|
60 |
+
if response.status_code == 200:
|
61 |
+
app_info = response.json()
|
62 |
+
return app_info.get("name", "Unknown App")
|
63 |
+
else:
|
64 |
+
logger.error(f"Failed to fetch app info for API key: {api_key[:8]}...")
|
65 |
+
return None
|
66 |
+
except Exception as e:
|
67 |
+
logger.error(f"Error fetching app info: {str(e)}")
|
68 |
+
return None
|
69 |
+
|
70 |
+
async def refresh_model_info(self):
|
71 |
+
"""刷新所有应用信息"""
|
72 |
+
self.name_to_api_key.clear()
|
73 |
+
self.api_key_to_name.clear()
|
74 |
+
|
75 |
+
for api_key in self.api_keys:
|
76 |
+
app_name = await self.fetch_app_info(api_key)
|
77 |
+
if app_name:
|
78 |
+
self.name_to_api_key[app_name] = api_key
|
79 |
+
self.api_key_to_name[api_key] = app_name
|
80 |
+
logger.info(f"Mapped app '{app_name}' to API key: {api_key[:8]}...")
|
81 |
+
|
82 |
+
def get_api_key(self, model_name):
|
83 |
+
"""根据模型名称获取API Key"""
|
84 |
+
return self.name_to_api_key.get(model_name)
|
85 |
+
|
86 |
+
def get_available_models(self):
|
87 |
+
"""获取可用模型列表"""
|
88 |
+
return [
|
89 |
+
{
|
90 |
+
"id": name,
|
91 |
+
"object": "model",
|
92 |
+
"created": int(time.time()),
|
93 |
+
"owned_by": "dify"
|
94 |
+
}
|
95 |
+
for name in self.name_to_api_key.keys()
|
96 |
+
]
|
97 |
+
|
98 |
+
# 创建模型管理器实例
|
99 |
+
model_manager = DifyModelManager()
|
100 |
+
|
101 |
+
# 从环境变量获取API基础URL
|
102 |
+
DIFY_API_BASE = os.getenv("DIFY_API_BASE", "")
|
103 |
+
|
104 |
+
app = Flask(__name__)
|
105 |
+
|
106 |
+
def get_api_key(model_name):
|
107 |
+
"""根据模型名称获取对应的API密钥"""
|
108 |
+
api_key = model_manager.get_api_key(model_name)
|
109 |
+
if not api_key:
|
110 |
+
logger.warning(f"No API key found for model: {model_name}")
|
111 |
+
return api_key
|
112 |
+
|
113 |
+
def transform_openai_to_dify(openai_request, endpoint):
|
114 |
+
"""将OpenAI格式的请求转换为Dify格式"""
|
115 |
+
|
116 |
+
if endpoint == "/chat/completions":
|
117 |
+
messages = openai_request.get("messages", [])
|
118 |
+
stream = openai_request.get("stream", False)
|
119 |
+
|
120 |
+
# 尝试从历史消息中提取conversation_id
|
121 |
+
conversation_id = None
|
122 |
+
|
123 |
+
# 提取system消息内容
|
124 |
+
system_content = ""
|
125 |
+
system_messages = [msg for msg in messages if msg.get("role") == "system"]
|
126 |
+
if system_messages:
|
127 |
+
system_content = system_messages[0].get("content", "")
|
128 |
+
# 记录找到的system消息
|
129 |
+
logger.info(f"Found system message: {system_content[:100]}{'...' if len(system_content) > 100 else ''}")
|
130 |
+
|
131 |
+
if CONVERSATION_MEMORY_MODE == 2: # 零宽字符模式
|
132 |
+
if len(messages) > 1:
|
133 |
+
# 遍历历史消息,找到最近的assistant消息
|
134 |
+
for msg in reversed(messages[:-1]): # 除了最后一条消息
|
135 |
+
if msg.get("role") == "assistant":
|
136 |
+
content = msg.get("content", "")
|
137 |
+
# 尝试解码conversation_id
|
138 |
+
conversation_id = decode_conversation_id(content)
|
139 |
+
if conversation_id:
|
140 |
+
break
|
141 |
+
|
142 |
+
# 获取最后一条用户消息
|
143 |
+
user_query = messages[-1]["content"] if messages and messages[-1].get("role") != "system" else ""
|
144 |
+
|
145 |
+
# 如果有system消息且是首次对话(没有conversation_id),则将system内容添加到用户查询前
|
146 |
+
if system_content and not conversation_id:
|
147 |
+
user_query = f"系统指令: {system_content}\n\n用户问题: {user_query}"
|
148 |
+
logger.info(f"[零宽字符模式] 首次对话,添加system内容到查询前")
|
149 |
+
|
150 |
+
dify_request = {
|
151 |
+
"inputs": {},
|
152 |
+
"query": user_query,
|
153 |
+
"response_mode": "streaming" if stream else "blocking",
|
154 |
+
"conversation_id": conversation_id,
|
155 |
+
"user": openai_request.get("user", "default_user")
|
156 |
+
}
|
157 |
+
else: # history_message模式(默认)
|
158 |
+
# 获取最后一条用户消息
|
159 |
+
user_query = messages[-1]["content"] if messages and messages[-1].get("role") != "system" else ""
|
160 |
+
|
161 |
+
# 构造历史消息
|
162 |
+
if len(messages) > 1:
|
163 |
+
history_messages = []
|
164 |
+
has_system_in_history = False
|
165 |
+
|
166 |
+
# 检查历史消息中是否已经包含system消息
|
167 |
+
for msg in messages[:-1]: # 除了最后一条消息
|
168 |
+
role = msg.get("role", "")
|
169 |
+
content = msg.get("content", "")
|
170 |
+
if role and content:
|
171 |
+
if role == "system":
|
172 |
+
has_system_in_history = True
|
173 |
+
history_messages.append(f"{role}: {content}")
|
174 |
+
|
175 |
+
# 如果历史中没有system消息但现在有system消息,则添加到历史的最前面
|
176 |
+
if system_content and not has_system_in_history:
|
177 |
+
history_messages.insert(0, f"system: {system_content}")
|
178 |
+
logger.info(f"[history_message模式] 添加system内容到历史消息前")
|
179 |
+
|
180 |
+
# 将历史消息添加到查询中
|
181 |
+
if history_messages:
|
182 |
+
history_context = "\n\n".join(history_messages)
|
183 |
+
user_query = f"<history>\n{history_context}\n</history>\n\n用户当前问题: {user_query}"
|
184 |
+
elif system_content: # 没有历史消息但有system消息
|
185 |
+
user_query = f"系统指令: {system_content}\n\n用户问题: {user_query}"
|
186 |
+
logger.info(f"[history_message模式] 首次对话,添加system内容到查询前")
|
187 |
+
|
188 |
+
dify_request = {
|
189 |
+
"inputs": {},
|
190 |
+
"query": user_query,
|
191 |
+
"response_mode": "streaming" if stream else "blocking",
|
192 |
+
"user": openai_request.get("user", "default_user")
|
193 |
+
}
|
194 |
+
|
195 |
+
return dify_request
|
196 |
+
|
197 |
+
return None
|
198 |
+
|
199 |
+
def transform_dify_to_openai(dify_response, model="claude-3-5-sonnet-v2", stream=False):
|
200 |
+
"""将Dify格式的响应转换为OpenAI格式"""
|
201 |
+
|
202 |
+
if not stream:
|
203 |
+
# 首先获取回答内容,支持不同的响应模式
|
204 |
+
answer = ""
|
205 |
+
mode = dify_response.get("mode", "")
|
206 |
+
|
207 |
+
# 普通聊天模式
|
208 |
+
if "answer" in dify_response:
|
209 |
+
answer = dify_response.get("answer", "")
|
210 |
+
|
211 |
+
# 如果是Agent模式,需要从agent_thoughts中提取回答
|
212 |
+
elif "agent_thoughts" in dify_response:
|
213 |
+
# Agent模式下通常最后一个thought包含最终答案
|
214 |
+
agent_thoughts = dify_response.get("agent_thoughts", [])
|
215 |
+
if agent_thoughts:
|
216 |
+
for thought in agent_thoughts:
|
217 |
+
if thought.get("thought"):
|
218 |
+
answer = thought.get("thought", "")
|
219 |
+
|
220 |
+
# 只在零宽字符会话记忆模式时处理conversation_id
|
221 |
+
if CONVERSATION_MEMORY_MODE == 2:
|
222 |
+
conversation_id = dify_response.get("conversation_id", "")
|
223 |
+
history = dify_response.get("conversation_history", [])
|
224 |
+
|
225 |
+
# 检查历史消息中是否已经有会话ID
|
226 |
+
has_conversation_id = False
|
227 |
+
if history:
|
228 |
+
for msg in history:
|
229 |
+
if msg.get("role") == "assistant":
|
230 |
+
content = msg.get("content", "")
|
231 |
+
if decode_conversation_id(content) is not None:
|
232 |
+
has_conversation_id = True
|
233 |
+
break
|
234 |
+
|
235 |
+
# 只在新会话且历史消息中没有会话ID时插入
|
236 |
+
if conversation_id and not has_conversation_id:
|
237 |
+
logger.info(f"[Debug] Inserting conversation_id: {conversation_id}, history_length: {len(history)}")
|
238 |
+
encoded = encode_conversation_id(conversation_id)
|
239 |
+
answer = answer + encoded
|
240 |
+
logger.info(f"[Debug] Response content after insertion: {repr(answer)}")
|
241 |
+
|
242 |
+
return {
|
243 |
+
"id": dify_response.get("message_id", ""),
|
244 |
+
"object": "chat.completion",
|
245 |
+
"created": dify_response.get("created", int(time.time())),
|
246 |
+
"model": model,
|
247 |
+
"choices": [{
|
248 |
+
"index": 0,
|
249 |
+
"message": {
|
250 |
+
"role": "assistant",
|
251 |
+
"content": answer
|
252 |
+
},
|
253 |
+
"finish_reason": "stop"
|
254 |
+
}]
|
255 |
+
}
|
256 |
+
else:
|
257 |
+
# 流式响应的转换在stream_response函数中处理
|
258 |
+
return dify_response
|
259 |
+
|
260 |
+
def create_openai_stream_response(content, message_id, model="claude-3-5-sonnet-v2"):
|
261 |
+
"""创建OpenAI格式的流式响应"""
|
262 |
+
return {
|
263 |
+
"id": message_id,
|
264 |
+
"object": "chat.completion.chunk",
|
265 |
+
"created": int(time.time()),
|
266 |
+
"model": model,
|
267 |
+
"choices": [{
|
268 |
+
"index": 0,
|
269 |
+
"delta": {
|
270 |
+
"content": content
|
271 |
+
},
|
272 |
+
"finish_reason": None
|
273 |
+
}]
|
274 |
+
}
|
275 |
+
|
276 |
+
def encode_conversation_id(conversation_id):
|
277 |
+
"""将conversation_id编码为不可见的字符序列"""
|
278 |
+
if not conversation_id:
|
279 |
+
return ""
|
280 |
+
|
281 |
+
# 使用Base64编码减少长度
|
282 |
+
import base64
|
283 |
+
encoded = base64.b64encode(conversation_id.encode()).decode()
|
284 |
+
|
285 |
+
# 使用8种不同的零宽字符表示3位数字
|
286 |
+
# 这样可以将编码长度进一步减少
|
287 |
+
char_map = {
|
288 |
+
'0': '\u200b', # 零宽空格
|
289 |
+
'1': '\u200c', # 零宽非连接符
|
290 |
+
'2': '\u200d', # 零宽连接符
|
291 |
+
'3': '\ufeff', # 零宽非断空格
|
292 |
+
'4': '\u2060', # 词组连接符
|
293 |
+
'5': '\u180e', # 蒙古语元音分隔符
|
294 |
+
'6': '\u2061', # 函数应用
|
295 |
+
'7': '\u2062', # 不可见乘号
|
296 |
+
}
|
297 |
+
|
298 |
+
# 将Base64字符串转换为八进制数字
|
299 |
+
result = []
|
300 |
+
for c in encoded:
|
301 |
+
# 将每个字符转换为8进制数字(0-7)
|
302 |
+
if c.isalpha():
|
303 |
+
if c.isupper():
|
304 |
+
val = ord(c) - ord('A')
|
305 |
+
else:
|
306 |
+
val = ord(c) - ord('a') + 26
|
307 |
+
elif c.isdigit():
|
308 |
+
val = int(c) + 52
|
309 |
+
elif c == '+':
|
310 |
+
val = 62
|
311 |
+
elif c == '/':
|
312 |
+
val = 63
|
313 |
+
else: # '='
|
314 |
+
val = 0
|
315 |
+
|
316 |
+
# 每个Base64字符可以产生2个3位数字
|
317 |
+
first = (val >> 3) & 0x7
|
318 |
+
second = val & 0x7
|
319 |
+
result.append(char_map[str(first)])
|
320 |
+
if c != '=': # 不编码填充字符的后半部分
|
321 |
+
result.append(char_map[str(second)])
|
322 |
+
|
323 |
+
return ''.join(result)
|
324 |
+
|
325 |
+
def decode_conversation_id(content):
|
326 |
+
"""从消息内容中解码conversation_id"""
|
327 |
+
try:
|
328 |
+
# 零宽字符到3位数字的映射
|
329 |
+
char_to_val = {
|
330 |
+
'\u200b': '0', # 零宽空格
|
331 |
+
'\u200c': '1', # 零宽非连接符
|
332 |
+
'\u200d': '2', # 零宽连接符
|
333 |
+
'\ufeff': '3', # 零宽非断空格
|
334 |
+
'\u2060': '4', # 词组连接符
|
335 |
+
'\u180e': '5', # 蒙古语元音分隔符
|
336 |
+
'\u2061': '6', # 函数应用
|
337 |
+
'\u2062': '7', # 不可见乘号
|
338 |
+
}
|
339 |
+
|
340 |
+
# 提取最后一段零宽字符序列
|
341 |
+
space_chars = []
|
342 |
+
for c in reversed(content):
|
343 |
+
if c not in char_to_val:
|
344 |
+
break
|
345 |
+
space_chars.append(c)
|
346 |
+
|
347 |
+
if not space_chars:
|
348 |
+
return None
|
349 |
+
|
350 |
+
# 将零宽字符转换回Base64字符串
|
351 |
+
space_chars.reverse()
|
352 |
+
base64_chars = []
|
353 |
+
for i in range(0, len(space_chars), 2):
|
354 |
+
first = int(char_to_val[space_chars[i]], 8)
|
355 |
+
if i + 1 < len(space_chars):
|
356 |
+
second = int(char_to_val[space_chars[i + 1]], 8)
|
357 |
+
val = (first << 3) | second
|
358 |
+
else:
|
359 |
+
val = first << 3
|
360 |
+
|
361 |
+
# 转换回Base64字符
|
362 |
+
if val < 26:
|
363 |
+
base64_chars.append(chr(val + ord('A')))
|
364 |
+
elif val < 52:
|
365 |
+
base64_chars.append(chr(val - 26 + ord('a')))
|
366 |
+
elif val < 62:
|
367 |
+
base64_chars.append(str(val - 52))
|
368 |
+
elif val == 62:
|
369 |
+
base64_chars.append('+')
|
370 |
+
else:
|
371 |
+
base64_chars.append('/')
|
372 |
+
|
373 |
+
# 添加Base64填充
|
374 |
+
padding = len(base64_chars) % 4
|
375 |
+
if padding:
|
376 |
+
base64_chars.extend(['='] * (4 - padding))
|
377 |
+
|
378 |
+
# 解码Base64字符串
|
379 |
+
import base64
|
380 |
+
base64_str = ''.join(base64_chars)
|
381 |
+
return base64.b64decode(base64_str).decode()
|
382 |
+
|
383 |
+
except Exception as e:
|
384 |
+
logger.debug(f"Failed to decode conversation_id: {e}")
|
385 |
+
return None
|
386 |
+
|
387 |
+
@app.route('/v1/chat/completions', methods=['POST'])
|
388 |
+
def chat_completions():
|
389 |
+
try:
|
390 |
+
# 新增:验证API密钥
|
391 |
+
auth_header = request.headers.get('Authorization')
|
392 |
+
if not auth_header:
|
393 |
+
return jsonify({
|
394 |
+
"error": {
|
395 |
+
"message": "Missing Authorization header",
|
396 |
+
"type": "invalid_request_error",
|
397 |
+
"param": None,
|
398 |
+
"code": "invalid_api_key"
|
399 |
+
}
|
400 |
+
}), 401
|
401 |
+
|
402 |
+
parts = auth_header.split()
|
403 |
+
if len(parts) != 2 or parts[0].lower() != 'bearer':
|
404 |
+
return jsonify({
|
405 |
+
"error": {
|
406 |
+
"message": "Invalid Authorization header format. Expected: Bearer <API_KEY>",
|
407 |
+
"type": "invalid_request_error",
|
408 |
+
"param": None,
|
409 |
+
"code": "invalid_api_key"
|
410 |
+
}
|
411 |
+
}), 401
|
412 |
+
|
413 |
+
provided_api_key = parts[1]
|
414 |
+
if provided_api_key not in VALID_API_KEYS:
|
415 |
+
return jsonify({
|
416 |
+
"error": {
|
417 |
+
"message": "Invalid API key",
|
418 |
+
"type": "invalid_request_error",
|
419 |
+
"param": None,
|
420 |
+
"code": "invalid_api_key"
|
421 |
+
}
|
422 |
+
}), 401
|
423 |
+
|
424 |
+
# 继续处理原始逻辑
|
425 |
+
openai_request = request.get_json()
|
426 |
+
logger.info(f"Received request: {json.dumps(openai_request, ensure_ascii=False)}")
|
427 |
+
|
428 |
+
model = openai_request.get("model", "claude-3-5-sonnet-v2")
|
429 |
+
|
430 |
+
# 验证模型是否支持
|
431 |
+
api_key = get_api_key(model)
|
432 |
+
if not api_key:
|
433 |
+
error_msg = f"Model {model} is not supported. Available models: {', '.join(model_manager.name_to_api_key.keys())}"
|
434 |
+
logger.error(error_msg)
|
435 |
+
return {
|
436 |
+
"error": {
|
437 |
+
"message": error_msg,
|
438 |
+
"type": "invalid_request_error",
|
439 |
+
"code": "model_not_found"
|
440 |
+
}
|
441 |
+
}, 404
|
442 |
+
|
443 |
+
dify_request = transform_openai_to_dify(openai_request, "/chat/completions")
|
444 |
+
|
445 |
+
if not dify_request:
|
446 |
+
logger.error("Failed to transform request")
|
447 |
+
return {
|
448 |
+
"error": {
|
449 |
+
"message": "Invalid request format",
|
450 |
+
"type": "invalid_request_error",
|
451 |
+
}
|
452 |
+
}, 400
|
453 |
+
|
454 |
+
headers = {
|
455 |
+
"Authorization": f"Bearer {api_key}",
|
456 |
+
"Content-Type": "application/json"
|
457 |
+
}
|
458 |
+
|
459 |
+
stream = openai_request.get("stream", False)
|
460 |
+
dify_endpoint = f"{DIFY_API_BASE}/chat-messages"
|
461 |
+
logger.info(f"Sending request to Dify endpoint: {dify_endpoint}, stream={stream}")
|
462 |
+
|
463 |
+
if stream:
|
464 |
+
def generate():
|
465 |
+
client = httpx.Client(timeout=None)
|
466 |
+
|
467 |
+
def flush_chunk(chunk_data):
|
468 |
+
"""Helper function to flush chunks immediately"""
|
469 |
+
return chunk_data.encode('utf-8')
|
470 |
+
|
471 |
+
def calculate_delay(buffer_size):
|
472 |
+
"""
|
473 |
+
根据缓冲区大小动态计算延迟
|
474 |
+
buffer_size: 缓冲区中剩余的字符数量
|
475 |
+
"""
|
476 |
+
if buffer_size > 30: # 缓冲区内容较多,快速输出
|
477 |
+
return 0.001 # 5ms延迟
|
478 |
+
elif buffer_size > 20: # 中等数量,适中速度
|
479 |
+
return 0.002 # 10ms延迟
|
480 |
+
elif buffer_size > 10: # 较少内容,稍慢速度
|
481 |
+
return 0.01 # 20ms延迟
|
482 |
+
else: # 内容很少,使用较慢的速度
|
483 |
+
return 0.02 # 30ms延迟
|
484 |
+
|
485 |
+
def send_char(char, message_id):
|
486 |
+
"""Helper function to send single character"""
|
487 |
+
openai_chunk = {
|
488 |
+
"id": message_id,
|
489 |
+
"object": "chat.completion.chunk",
|
490 |
+
"created": int(time.time()),
|
491 |
+
"model": model,
|
492 |
+
"choices": [{
|
493 |
+
"index": 0,
|
494 |
+
"delta": {
|
495 |
+
"content": char
|
496 |
+
},
|
497 |
+
"finish_reason": None
|
498 |
+
}]
|
499 |
+
}
|
500 |
+
chunk_data = f"data: {json.dumps(openai_chunk)}\n\n"
|
501 |
+
return flush_chunk(chunk_data)
|
502 |
+
|
503 |
+
# 初始化缓冲区
|
504 |
+
output_buffer = []
|
505 |
+
|
506 |
+
try:
|
507 |
+
with client.stream(
|
508 |
+
'POST',
|
509 |
+
dify_endpoint,
|
510 |
+
json=dify_request,
|
511 |
+
headers={
|
512 |
+
**headers,
|
513 |
+
'Accept': 'text/event-stream',
|
514 |
+
'Cache-Control': 'no-cache',
|
515 |
+
'Connection': 'keep-alive'
|
516 |
+
}
|
517 |
+
) as response:
|
518 |
+
generate.message_id = None
|
519 |
+
buffer = ""
|
520 |
+
|
521 |
+
for raw_bytes in response.iter_raw():
|
522 |
+
if not raw_bytes:
|
523 |
+
continue
|
524 |
+
|
525 |
+
try:
|
526 |
+
buffer += raw_bytes.decode('utf-8')
|
527 |
+
|
528 |
+
while '\n' in buffer:
|
529 |
+
line, buffer = buffer.split('\n', 1)
|
530 |
+
line = line.strip()
|
531 |
+
|
532 |
+
if not line or not line.startswith('data: '):
|
533 |
+
continue
|
534 |
+
|
535 |
+
try:
|
536 |
+
json_str = line[6:]
|
537 |
+
dify_chunk = json.loads(json_str)
|
538 |
+
|
539 |
+
if dify_chunk.get("event") == "message" and "answer" in dify_chunk:
|
540 |
+
current_answer = dify_chunk["answer"]
|
541 |
+
if not current_answer:
|
542 |
+
continue
|
543 |
+
|
544 |
+
message_id = dify_chunk.get("message_id", "")
|
545 |
+
if not generate.message_id:
|
546 |
+
generate.message_id = message_id
|
547 |
+
|
548 |
+
# 将当前批次的字符添加到输出缓冲区
|
549 |
+
for char in current_answer:
|
550 |
+
output_buffer.append((char, generate.message_id))
|
551 |
+
|
552 |
+
# 根据缓冲区大小动态调整输出速度
|
553 |
+
while output_buffer:
|
554 |
+
char, msg_id = output_buffer.pop(0)
|
555 |
+
yield send_char(char, msg_id)
|
556 |
+
# 根据剩余缓冲区大小计算延迟
|
557 |
+
delay = calculate_delay(len(output_buffer))
|
558 |
+
time.sleep(delay)
|
559 |
+
|
560 |
+
# 立即继续处理下一个请求
|
561 |
+
continue
|
562 |
+
|
563 |
+
# 处理Agent模式的消息事件
|
564 |
+
elif dify_chunk.get("event") == "agent_message" and "answer" in dify_chunk:
|
565 |
+
current_answer = dify_chunk["answer"]
|
566 |
+
if not current_answer:
|
567 |
+
continue
|
568 |
+
|
569 |
+
message_id = dify_chunk.get("message_id", "")
|
570 |
+
if not generate.message_id:
|
571 |
+
generate.message_id = message_id
|
572 |
+
|
573 |
+
# 将当前批次的字符添加到输出缓冲区
|
574 |
+
for char in current_answer:
|
575 |
+
output_buffer.append((char, generate.message_id))
|
576 |
+
|
577 |
+
# 根据缓冲区大小动态调整输出速度
|
578 |
+
while output_buffer:
|
579 |
+
char, msg_id = output_buffer.pop(0)
|
580 |
+
yield send_char(char, msg_id)
|
581 |
+
# 根据剩余缓冲区大小计算延迟
|
582 |
+
delay = calculate_delay(len(output_buffer))
|
583 |
+
time.sleep(delay)
|
584 |
+
|
585 |
+
# 立即继续处理下一个请求
|
586 |
+
continue
|
587 |
+
|
588 |
+
# 处理Agent的思考过程,记录日志但不输出给用户
|
589 |
+
elif dify_chunk.get("event") == "agent_thought":
|
590 |
+
thought_id = dify_chunk.get("id", "")
|
591 |
+
thought = dify_chunk.get("thought", "")
|
592 |
+
tool = dify_chunk.get("tool", "")
|
593 |
+
tool_input = dify_chunk.get("tool_input", "")
|
594 |
+
observation = dify_chunk.get("observation", "")
|
595 |
+
|
596 |
+
logger.info(f"[Agent Thought] ID: {thought_id}, Tool: {tool}")
|
597 |
+
if thought:
|
598 |
+
logger.info(f"[Agent Thought] Thought: {thought}")
|
599 |
+
if tool_input:
|
600 |
+
logger.info(f"[Agent Thought] Tool Input: {tool_input}")
|
601 |
+
if observation:
|
602 |
+
logger.info(f"[Agent Thought] Observation: {observation}")
|
603 |
+
|
604 |
+
# 获取message_id以关联思考和最终输出
|
605 |
+
message_id = dify_chunk.get("message_id", "")
|
606 |
+
if not generate.message_id and message_id:
|
607 |
+
generate.message_id = message_id
|
608 |
+
|
609 |
+
continue
|
610 |
+
|
611 |
+
# 处理消息中的文件(如图片),记录日志但不直接输出给用户
|
612 |
+
elif dify_chunk.get("event") == "message_file":
|
613 |
+
file_id = dify_chunk.get("id", "")
|
614 |
+
file_type = dify_chunk.get("type", "")
|
615 |
+
file_url = dify_chunk.get("url", "")
|
616 |
+
|
617 |
+
logger.info(f"[Message File] ID: {file_id}, Type: {file_type}, URL: {file_url}")
|
618 |
+
continue
|
619 |
+
|
620 |
+
elif dify_chunk.get("event") == "message_end":
|
621 |
+
# 快速输出剩余内容
|
622 |
+
while output_buffer:
|
623 |
+
char, msg_id = output_buffer.pop(0)
|
624 |
+
yield send_char(char, msg_id)
|
625 |
+
time.sleep(0.001) # 固定使用最小延迟快速输出剩余内容
|
626 |
+
|
627 |
+
# 只在零宽字符会话记忆模式时处理conversation_id
|
628 |
+
if CONVERSATION_MEMORY_MODE == 2:
|
629 |
+
conversation_id = dify_chunk.get("conversation_id")
|
630 |
+
history = dify_chunk.get("conversation_history", [])
|
631 |
+
|
632 |
+
has_conversation_id = False
|
633 |
+
if history:
|
634 |
+
for msg in history:
|
635 |
+
if msg.get("role") == "assistant":
|
636 |
+
content = msg.get("content", "")
|
637 |
+
if decode_conversation_id(content) is not None:
|
638 |
+
has_conversation_id = True
|
639 |
+
break
|
640 |
+
|
641 |
+
# 只在新会话且历史消息中没有会话ID时插入
|
642 |
+
if conversation_id and not has_conversation_id:
|
643 |
+
logger.info(f"[Debug] Inserting conversation_id in stream: {conversation_id}")
|
644 |
+
encoded = encode_conversation_id(conversation_id)
|
645 |
+
logger.info(f"[Debug] Stream encoded content: {repr(encoded)}")
|
646 |
+
for char in encoded:
|
647 |
+
yield send_char(char, generate.message_id)
|
648 |
+
|
649 |
+
final_chunk = {
|
650 |
+
"id": generate.message_id,
|
651 |
+
"object": "chat.completion.chunk",
|
652 |
+
"created": int(time.time()),
|
653 |
+
"model": model,
|
654 |
+
"choices": [{
|
655 |
+
"index": 0,
|
656 |
+
"delta": {},
|
657 |
+
"finish_reason": "stop"
|
658 |
+
}]
|
659 |
+
}
|
660 |
+
yield flush_chunk(f"data: {json.dumps(final_chunk)}\n\n")
|
661 |
+
yield flush_chunk("data: [DONE]\n\n")
|
662 |
+
|
663 |
+
except json.JSONDecodeError as e:
|
664 |
+
logger.error(f"JSON decode error: {str(e)}")
|
665 |
+
continue
|
666 |
+
|
667 |
+
except Exception as e:
|
668 |
+
logger.error(f"Error processing chunk: {str(e)}")
|
669 |
+
continue
|
670 |
+
|
671 |
+
finally:
|
672 |
+
client.close()
|
673 |
+
|
674 |
+
return Response(
|
675 |
+
stream_with_context(generate()),
|
676 |
+
content_type='text/event-stream',
|
677 |
+
headers={
|
678 |
+
'Cache-Control': 'no-cache, no-transform',
|
679 |
+
'Connection': 'keep-alive',
|
680 |
+
'Transfer-Encoding': 'chunked',
|
681 |
+
'X-Accel-Buffering': 'no',
|
682 |
+
'Content-Encoding': 'none'
|
683 |
+
},
|
684 |
+
direct_passthrough=True
|
685 |
+
)
|
686 |
+
else:
|
687 |
+
async def sync_response():
|
688 |
+
try:
|
689 |
+
async with httpx.AsyncClient() as client:
|
690 |
+
response = await client.post(
|
691 |
+
dify_endpoint,
|
692 |
+
json=dify_request,
|
693 |
+
headers=headers
|
694 |
+
)
|
695 |
+
|
696 |
+
if response.status_code != 200:
|
697 |
+
error_msg = f"Dify API error: {response.text}"
|
698 |
+
logger.error(f"Request failed: {error_msg}")
|
699 |
+
return {
|
700 |
+
"error": {
|
701 |
+
"message": error_msg,
|
702 |
+
"type": "api_error",
|
703 |
+
"code": response.status_code
|
704 |
+
}
|
705 |
+
}, response.status_code
|
706 |
+
|
707 |
+
dify_response = response.json()
|
708 |
+
logger.info(f"Received response from Dify: {json.dumps(dify_response, ensure_ascii=False)}")
|
709 |
+
logger.info(f"[Debug] Response content: {repr(dify_response.get('answer', ''))}")
|
710 |
+
openai_response = transform_dify_to_openai(dify_response, model=model)
|
711 |
+
conversation_id = dify_response.get("conversation_id")
|
712 |
+
if conversation_id:
|
713 |
+
# 在响应头中传递conversation_id
|
714 |
+
return Response(
|
715 |
+
json.dumps(openai_response),
|
716 |
+
content_type='application/json',
|
717 |
+
headers={
|
718 |
+
'Conversation-Id': conversation_id
|
719 |
+
}
|
720 |
+
)
|
721 |
+
else:
|
722 |
+
return openai_response
|
723 |
+
except httpx.RequestError as e:
|
724 |
+
error_msg = f"Failed to connect to Dify: {str(e)}"
|
725 |
+
logger.error(error_msg)
|
726 |
+
return {
|
727 |
+
"error": {
|
728 |
+
"message": error_msg,
|
729 |
+
"type": "api_error",
|
730 |
+
"code": "connection_error"
|
731 |
+
}
|
732 |
+
}, 503
|
733 |
+
|
734 |
+
return asyncio.run(sync_response())
|
735 |
+
|
736 |
+
except Exception as e:
|
737 |
+
logger.exception("Unexpected error occurred")
|
738 |
+
return {
|
739 |
+
"error": {
|
740 |
+
"message": str(e),
|
741 |
+
"type": "internal_error",
|
742 |
+
}
|
743 |
+
}, 500
|
744 |
+
|
745 |
+
@app.route('/v1/models', methods=['GET'])
|
746 |
+
def list_models():
|
747 |
+
"""返回可用的模型列表"""
|
748 |
+
logger.info("Listing available models")
|
749 |
+
|
750 |
+
# 刷新模型信息
|
751 |
+
asyncio.run(model_manager.refresh_model_info())
|
752 |
+
|
753 |
+
# 获取可用模型列表
|
754 |
+
available_models = model_manager.get_available_models()
|
755 |
+
|
756 |
+
response = {
|
757 |
+
"object": "list",
|
758 |
+
"data": available_models
|
759 |
+
}
|
760 |
+
logger.info(f"Available models: {json.dumps(response, ensure_ascii=False)}")
|
761 |
+
return response
|
762 |
+
|
763 |
+
# 在main.py的最后初始化时添加环境变量检查:
|
764 |
+
if __name__ == "__main__":
|
765 |
+
# 刷新模型信息
|
766 |
+
asyncio.run(model_manager.refresh_model_info())
|
767 |
+
|
768 |
+
# 从环境变量获取服务器配置
|
769 |
+
host = os.getenv("SERVER_HOST", "0.0.0.0") # 在Docker中使用0.0.0.0
|
770 |
+
port = int(os.getenv("SERVER_PORT", 7860)) # Hugging Face Spaces默认使用7860端口
|
771 |
+
|
772 |
+
logger.info(f"Starting server on {host}:{port}")
|
773 |
+
app.run(host=host, port=port)
|
requirements.txt
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
flask
|
2 |
+
httpx
|
3 |
+
python-dotenv
|