Delete fairies_proxy.py
Browse files- fairies_proxy.py +0 -713
fairies_proxy.py
DELETED
|
@@ -1,713 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Fairies AI to OpenAI API Proxy v2.0
|
| 4 |
-
支持本地API密钥验证、模型列表、WebSocket连接池和流式响应
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import asyncio
|
| 8 |
-
import json
|
| 9 |
-
import os
|
| 10 |
-
import signal
|
| 11 |
-
import time
|
| 12 |
-
import uuid
|
| 13 |
-
import websockets
|
| 14 |
-
import requests
|
| 15 |
-
from flask import Flask, request, jsonify, Response
|
| 16 |
-
from waitress import serve
|
| 17 |
-
from datetime import datetime
|
| 18 |
-
from typing import Dict, List, Optional, Any
|
| 19 |
-
import threading
|
| 20 |
-
|
| 21 |
-
# 配置信息
|
| 22 |
-
FAIRIES_COOKIE_STRING = os.getenv('FAIRIES_COOKIE_STRING', '')
|
| 23 |
-
LOCAL_API_KEY = os.getenv('FAIRIES_API_KEY', 'sk-fairies-proxy-2025-v2')
|
| 24 |
-
|
| 25 |
-
# 支持的模型列表
|
| 26 |
-
AVAILABLE_MODELS = [
|
| 27 |
-
{"id": "claude-sonnet-4-20250514", "object": "model", "created": int(time.time()), "owned_by": "anthropic"},
|
| 28 |
-
{"id": "gpt-4.1", "object": "model", "created": int(time.time()), "owned_by": "openai"},
|
| 29 |
-
{"id": "gemini-2.5-flash", "object": "model", "created": int(time.time()), "owned_by": "google"},
|
| 30 |
-
{"id": "gemini-2.5-pro", "object": "model", "created": int(time.time()), "owned_by": "google"},
|
| 31 |
-
{"id": "gpt-4.1-mini", "object": "model", "created": int(time.time()), "owned_by": "openai"},
|
| 32 |
-
{"id": "x-ai/grok-3-mini", "object": "model", "created": int(time.time()), "owned_by": "x-ai"}
|
| 33 |
-
]
|
| 34 |
-
|
| 35 |
-
PORT = int(os.getenv('PORT', 7001))
|
| 36 |
-
app = Flask(__name__)
|
| 37 |
-
|
| 38 |
-
# 验证必要的环境变量
|
| 39 |
-
if not FAIRIES_COOKIE_STRING:
|
| 40 |
-
print("⚠️ 警告: FAIRIES_COOKIE_STRING 环境变量未设置!")
|
| 41 |
-
print(" 请设置: export FAIRIES_COOKIE_STRING='your_cookie_string'")
|
| 42 |
-
|
| 43 |
-
print(f"🔧 配置信息:")
|
| 44 |
-
print(f" API密钥: {LOCAL_API_KEY}")
|
| 45 |
-
print(f" 端口: {PORT}")
|
| 46 |
-
print(f" Cookie状态: {'✅ 已设置' if FAIRIES_COOKIE_STRING else '❌ 未设置'}")
|
| 47 |
-
print()
|
| 48 |
-
|
| 49 |
-
class FairiesAIClient:
|
| 50 |
-
def __init__(self, cookie_string):
|
| 51 |
-
self.cookie_string = cookie_string
|
| 52 |
-
self.api_base = "https://fairies.ai/api"
|
| 53 |
-
self.websocket_url = "wss://fairies.ai/api/agent/ws"
|
| 54 |
-
|
| 55 |
-
# 预配置的Agent ID(固定不变)
|
| 56 |
-
self.PREDEFINED_AGENTS = {
|
| 57 |
-
"MAIN": "4f18843d-8823-4151-99e2-00fa87eec890",
|
| 58 |
-
"DEEPRESEARCH": "5b84e999-08e5-431f-a188-af22c1a8db2c",
|
| 59 |
-
"PERCEPTOR": "69611193-859e-4b5c-b1ee-2d2ea6ab25b2",
|
| 60 |
-
"AUTOMATOR": "fc59b81a-5e0a-48e9-b78f-be2b66949d37"
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
self.headers = {
|
| 64 |
-
"Content-Type": "application/json",
|
| 65 |
-
"Cookie": cookie_string,
|
| 66 |
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
print(f"🔧 初始化FairiesAI客户端")
|
| 70 |
-
print(f" 预配置Agent数量: {len(self.PREDEFINED_AGENTS)}")
|
| 71 |
-
|
| 72 |
-
def get_agent_id(self, agent_type="MAIN"):
|
| 73 |
-
"""获取预配置的Agent ID"""
|
| 74 |
-
agent_id = self.PREDEFINED_AGENTS.get(agent_type.upper())
|
| 75 |
-
if agent_id:
|
| 76 |
-
print(f"✅ 使用预配置Agent: {agent_type} -> {agent_id}")
|
| 77 |
-
return agent_id
|
| 78 |
-
else:
|
| 79 |
-
print(f"⚠️ 未知的Agent类型: {agent_type},使用默认MAIN")
|
| 80 |
-
return self.PREDEFINED_AGENTS["MAIN"]
|
| 81 |
-
|
| 82 |
-
async def create_websocket_connection(self):
|
| 83 |
-
"""为每个请求创建独立的WebSocket连接"""
|
| 84 |
-
session_token = None
|
| 85 |
-
for cookie_part in self.cookie_string.split(';'):
|
| 86 |
-
if 'session=' in cookie_part:
|
| 87 |
-
session_token = cookie_part.split('session=')[1].strip()
|
| 88 |
-
break
|
| 89 |
-
|
| 90 |
-
if not session_token:
|
| 91 |
-
raise Exception("No session token found in cookies")
|
| 92 |
-
|
| 93 |
-
print(f"🔗 创建独立WebSocket连接")
|
| 94 |
-
|
| 95 |
-
ws_headers = {
|
| 96 |
-
"session-token": session_token,
|
| 97 |
-
"Cookie": f"session={session_token}",
|
| 98 |
-
"User-Agent": "Altera-Agent/1.0"
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
try:
|
| 102 |
-
# 兼容不同版本的websockets库
|
| 103 |
-
try:
|
| 104 |
-
# 新版本使用extra_headers
|
| 105 |
-
websocket = await asyncio.wait_for(
|
| 106 |
-
websockets.connect(
|
| 107 |
-
self.websocket_url,
|
| 108 |
-
extra_headers=ws_headers,
|
| 109 |
-
ping_interval=30, # 30秒ping间隔
|
| 110 |
-
ping_timeout=10, # 10秒ping超时
|
| 111 |
-
close_timeout=10 # 10秒关闭超时
|
| 112 |
-
),
|
| 113 |
-
timeout=30.0 # 连接超时30秒
|
| 114 |
-
)
|
| 115 |
-
except TypeError:
|
| 116 |
-
# 旧版本使用additional_headers
|
| 117 |
-
websocket = await asyncio.wait_for(
|
| 118 |
-
websockets.connect(
|
| 119 |
-
self.websocket_url,
|
| 120 |
-
additional_headers=ws_headers,
|
| 121 |
-
ping_interval=30, # 30秒ping间隔
|
| 122 |
-
ping_timeout=10, # 10秒ping超时
|
| 123 |
-
close_timeout=10 # 10秒关闭超时
|
| 124 |
-
),
|
| 125 |
-
timeout=30.0 # 连接超时30秒
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
print("✅ WebSocket连接成功")
|
| 129 |
-
return websocket
|
| 130 |
-
except asyncio.TimeoutError:
|
| 131 |
-
print(f"❌ WebSocket连接超时")
|
| 132 |
-
raise Exception("WebSocket connection timeout")
|
| 133 |
-
except websockets.exceptions.InvalidStatusCode as e:
|
| 134 |
-
if e.status_code in [401, 403]:
|
| 135 |
-
print(f"❌ WebSocket认证失败: {e.status_code}")
|
| 136 |
-
print("💡 提示: 请检查FAIRIES_COOKIE_STRING是否有效")
|
| 137 |
-
raise Exception(f"Authentication failed: {e.status_code}")
|
| 138 |
-
elif e.status_code == 429:
|
| 139 |
-
print(f"❌ WebSocket连接频率限制: {e.status_code}")
|
| 140 |
-
raise Exception(f"Rate limit exceeded: {e.status_code}")
|
| 141 |
-
elif e.status_code >= 500:
|
| 142 |
-
print(f"❌ WebSocket服务器错误: {e.status_code}")
|
| 143 |
-
raise Exception(f"Server error: {e.status_code}")
|
| 144 |
-
else:
|
| 145 |
-
print(f"❌ WebSocket连接失败,状态码: {e.status_code}")
|
| 146 |
-
raise Exception(f"WebSocket connection failed: {e.status_code}")
|
| 147 |
-
except Exception as e:
|
| 148 |
-
print(f"❌ WebSocket连接失败: {str(e)}")
|
| 149 |
-
raise
|
| 150 |
-
|
| 151 |
-
async def chat_stream(self, agent_id, query, model="claude-sonnet-4-20250514"):
|
| 152 |
-
"""异步流式聊天方法 - 每个请求使用独立连接"""
|
| 153 |
-
websocket = None
|
| 154 |
-
try:
|
| 155 |
-
websocket = await self.create_websocket_connection()
|
| 156 |
-
|
| 157 |
-
# 使用昨天能工作的消息格式
|
| 158 |
-
user_message = {
|
| 159 |
-
"type": "USER_MESSAGE",
|
| 160 |
-
"agent_id": agent_id,
|
| 161 |
-
"observation": {
|
| 162 |
-
"message": {
|
| 163 |
-
"user_query": query,
|
| 164 |
-
"mode": "main",
|
| 165 |
-
"files": [],
|
| 166 |
-
"memories": [],
|
| 167 |
-
"model": model,
|
| 168 |
-
"task_id": None,
|
| 169 |
-
"workspace_path": "/"
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
await websocket.send(json.dumps(user_message))
|
| 175 |
-
print("📤 消息已发送")
|
| 176 |
-
|
| 177 |
-
is_assistant_turn = False
|
| 178 |
-
delta_count = 0
|
| 179 |
-
response_completed = False
|
| 180 |
-
|
| 181 |
-
try:
|
| 182 |
-
# 使用昨天能工作的消息处理逻辑
|
| 183 |
-
async for message in websocket:
|
| 184 |
-
try:
|
| 185 |
-
try:
|
| 186 |
-
inner_json_string = json.loads(message)
|
| 187 |
-
parsed_message = json.loads(inner_json_string)
|
| 188 |
-
except (json.JSONDecodeError, TypeError):
|
| 189 |
-
parsed_message = json.loads(message)
|
| 190 |
-
|
| 191 |
-
msg_type = parsed_message.get("type")
|
| 192 |
-
|
| 193 |
-
if msg_type == "CHAT_MESSAGE":
|
| 194 |
-
inner_msg = parsed_message.get("message", {}).get("message", {})
|
| 195 |
-
inner_type = inner_msg.get("type")
|
| 196 |
-
role = inner_msg.get("item", {}).get("role")
|
| 197 |
-
|
| 198 |
-
if inner_type == "response.output_item.added" and role == "assistant":
|
| 199 |
-
is_assistant_turn = True
|
| 200 |
-
print("🤖 AI开始回复...")
|
| 201 |
-
|
| 202 |
-
elif is_assistant_turn and inner_type == "response.output_text.delta":
|
| 203 |
-
delta_text = inner_msg.get("delta", "")
|
| 204 |
-
if delta_text:
|
| 205 |
-
delta_count += 1
|
| 206 |
-
yield {"delta": delta_text}
|
| 207 |
-
|
| 208 |
-
elif inner_type == "response.output_item.done" and role == "assistant":
|
| 209 |
-
is_assistant_turn = False
|
| 210 |
-
response_completed = True
|
| 211 |
-
print(f"✅ AI回复完成,共收到 {delta_count} 个delta")
|
| 212 |
-
yield {"done": True}
|
| 213 |
-
break
|
| 214 |
-
|
| 215 |
-
# 处理任务相关消息
|
| 216 |
-
elif msg_type == "TASK_UPDATE":
|
| 217 |
-
task_data = parsed_message.get("task", {})
|
| 218 |
-
print(f"📋 任务更新: {task_data.get('status', 'unknown')}")
|
| 219 |
-
|
| 220 |
-
except Exception as e:
|
| 221 |
-
print(f"⚠️ 处理消息时出错: {e}")
|
| 222 |
-
continue
|
| 223 |
-
|
| 224 |
-
except asyncio.TimeoutError:
|
| 225 |
-
print("⚠️ WebSocket消息接收超时")
|
| 226 |
-
yield {"error": "Message receive timeout"}
|
| 227 |
-
except websockets.exceptions.ConnectionClosed:
|
| 228 |
-
if not response_completed:
|
| 229 |
-
print("⚠️ WebSocket连接意外关闭")
|
| 230 |
-
yield {"error": "Connection closed unexpectedly"}
|
| 231 |
-
else:
|
| 232 |
-
print("✅ WebSocket连接正常关闭")
|
| 233 |
-
|
| 234 |
-
except Exception as e:
|
| 235 |
-
print(f"❌ WebSocket通信失败: {str(e)}")
|
| 236 |
-
yield {"error": str(e)}
|
| 237 |
-
finally:
|
| 238 |
-
# 确保WebSocket连接正确关闭
|
| 239 |
-
if websocket:
|
| 240 |
-
try:
|
| 241 |
-
await websocket.close()
|
| 242 |
-
print("🔒 WebSocket连接已关闭")
|
| 243 |
-
except Exception as e:
|
| 244 |
-
print(f"⚠️ 关闭WebSocket连接时出错: {e}")
|
| 245 |
-
|
| 246 |
-
async def close_websocket_pool(self):
|
| 247 |
-
"""关闭所有WebSocket连接"""
|
| 248 |
-
# 移除WebSocket连接池的清理逻辑
|
| 249 |
-
print("🔒 移除WebSocket连接池的清理逻辑")
|
| 250 |
-
|
| 251 |
-
def list_tasks(self):
|
| 252 |
-
"""获取任务列表"""
|
| 253 |
-
try:
|
| 254 |
-
response = requests.get(f"{self.api_base}/workflow/tasks", headers=self.headers, timeout=10)
|
| 255 |
-
response.raise_for_status()
|
| 256 |
-
tasks = response.json()
|
| 257 |
-
print(f"📋 获取到 {len(tasks)} 个任务")
|
| 258 |
-
return tasks
|
| 259 |
-
except Exception as e:
|
| 260 |
-
print(f"❌ 获取任务列表失败: {e}")
|
| 261 |
-
return []
|
| 262 |
-
|
| 263 |
-
def update_task_status(self, task_id, status):
|
| 264 |
-
"""更新任务状态"""
|
| 265 |
-
try:
|
| 266 |
-
payload = {"status": status}
|
| 267 |
-
response = requests.put(f"{self.api_base}/workflow/tasks/{task_id}",
|
| 268 |
-
headers=self.headers, json=payload)
|
| 269 |
-
response.raise_for_status()
|
| 270 |
-
print(f"✅ 任务 {task_id} 状态更新为: {status}")
|
| 271 |
-
return True
|
| 272 |
-
except Exception as e:
|
| 273 |
-
print(f"❌ 更新任务状态失败: {e}")
|
| 274 |
-
return False
|
| 275 |
-
|
| 276 |
-
def delete_task(self, task_id):
|
| 277 |
-
"""删除任务"""
|
| 278 |
-
try:
|
| 279 |
-
response = requests.delete(f"{self.api_base}/workflow/tasks/{task_id}", headers=self.headers)
|
| 280 |
-
response.raise_for_status()
|
| 281 |
-
print(f"🗑️ 任务 {task_id} 已删除")
|
| 282 |
-
return True
|
| 283 |
-
except Exception as e:
|
| 284 |
-
print(f"❌ 删除任务失败: {e}")
|
| 285 |
-
return False
|
| 286 |
-
|
| 287 |
-
def validate_connection(self):
|
| 288 |
-
"""验证连接配置"""
|
| 289 |
-
issues = []
|
| 290 |
-
|
| 291 |
-
# 检查Cookie
|
| 292 |
-
if not self.cookie_string:
|
| 293 |
-
issues.append("FAIRIES_COOKIE_STRING未设置")
|
| 294 |
-
else:
|
| 295 |
-
# 检查session token
|
| 296 |
-
session_token = None
|
| 297 |
-
for cookie_part in self.cookie_string.split(';'):
|
| 298 |
-
if 'session=' in cookie_part:
|
| 299 |
-
session_token = cookie_part.split('session=')[1].strip()
|
| 300 |
-
break
|
| 301 |
-
if not session_token:
|
| 302 |
-
issues.append("Cookie中未找到有效的session token")
|
| 303 |
-
|
| 304 |
-
if issues:
|
| 305 |
-
print("⚠️ 连接配置问题:")
|
| 306 |
-
for issue in issues:
|
| 307 |
-
print(f" - {issue}")
|
| 308 |
-
return False
|
| 309 |
-
else:
|
| 310 |
-
print("✅ 连接配置验证通过")
|
| 311 |
-
return True
|
| 312 |
-
|
| 313 |
-
async def health_check(self):
|
| 314 |
-
"""健康检查"""
|
| 315 |
-
try:
|
| 316 |
-
# 测试WebSocket连接
|
| 317 |
-
websocket = await self.create_websocket_connection()
|
| 318 |
-
await websocket.ping()
|
| 319 |
-
await websocket.close() # 立即关闭测试连接
|
| 320 |
-
print("✅ WebSocket连接健康")
|
| 321 |
-
return {"websocket": "healthy", "status": "ok"}
|
| 322 |
-
except Exception as e:
|
| 323 |
-
print(f"❌ WebSocket连接不健康: {e}")
|
| 324 |
-
return {"websocket": "unhealthy", "status": "error", "error": str(e)}
|
| 325 |
-
|
| 326 |
-
# 全局客户端实例
|
| 327 |
-
fairies_client = FairiesAIClient(FAIRIES_COOKIE_STRING)
|
| 328 |
-
|
| 329 |
-
def verify_api_key():
|
| 330 |
-
"""验证API密钥"""
|
| 331 |
-
auth_header = request.headers.get('Authorization')
|
| 332 |
-
if not auth_header:
|
| 333 |
-
return False, jsonify({"error": {"message": "Authorization header is required", "type": "invalid_request_error"}}), 401
|
| 334 |
-
|
| 335 |
-
if not auth_header.startswith('Bearer '):
|
| 336 |
-
return False, jsonify({"error": {"message": "Invalid authorization header format", "type": "invalid_request_error"}}), 401
|
| 337 |
-
|
| 338 |
-
api_key = auth_header.split(' ')[1]
|
| 339 |
-
if api_key != LOCAL_API_KEY:
|
| 340 |
-
return False, jsonify({"error": {"message": "Invalid API key", "type": "invalid_request_error"}}), 401
|
| 341 |
-
|
| 342 |
-
return True, None, None
|
| 343 |
-
|
| 344 |
-
@app.route('/v1/models', methods=['GET'])
|
| 345 |
-
def list_models():
|
| 346 |
-
"""返回可用模型列表"""
|
| 347 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 348 |
-
if not is_valid:
|
| 349 |
-
return error_response, status_code
|
| 350 |
-
|
| 351 |
-
return jsonify({
|
| 352 |
-
"object": "list",
|
| 353 |
-
"data": AVAILABLE_MODELS
|
| 354 |
-
})
|
| 355 |
-
|
| 356 |
-
@app.route('/v1/chat/completions', methods=['POST'])
|
| 357 |
-
def chat_completions():
|
| 358 |
-
"""OpenAI兼容的聊天完成接口"""
|
| 359 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 360 |
-
if not is_valid:
|
| 361 |
-
return error_response, status_code
|
| 362 |
-
|
| 363 |
-
data = request.get_json()
|
| 364 |
-
if not data:
|
| 365 |
-
return jsonify({"error": {"message": "Request body is required", "type": "invalid_request_error"}}), 400
|
| 366 |
-
|
| 367 |
-
messages = data.get('messages', [])
|
| 368 |
-
stream = data.get('stream', False)
|
| 369 |
-
model = data.get('model', 'claude-sonnet-4-20250514')
|
| 370 |
-
|
| 371 |
-
if not messages:
|
| 372 |
-
return jsonify({"error": {"message": "messages field is required", "type": "invalid_request_error"}}), 400
|
| 373 |
-
|
| 374 |
-
# 提取最后一条用户消息
|
| 375 |
-
user_message = None
|
| 376 |
-
for msg in reversed(messages):
|
| 377 |
-
if msg.get('role') == 'user':
|
| 378 |
-
user_message = msg.get('content')
|
| 379 |
-
break
|
| 380 |
-
|
| 381 |
-
if not user_message:
|
| 382 |
-
return jsonify({"error": {"message": "No user message found", "type": "invalid_request_error"}}), 400
|
| 383 |
-
|
| 384 |
-
print(f"👤 用户消息: {user_message}")
|
| 385 |
-
print(f"📡 流式模式: {stream}")
|
| 386 |
-
print(f"🎯 模型: {model}")
|
| 387 |
-
|
| 388 |
-
try:
|
| 389 |
-
agent_id = fairies_client.get_agent_id()
|
| 390 |
-
|
| 391 |
-
if not agent_id:
|
| 392 |
-
return jsonify({"error": {"message": "Failed to get agent ID", "type": "internal_server_error"}}), 500
|
| 393 |
-
|
| 394 |
-
if stream:
|
| 395 |
-
def generate_stream():
|
| 396 |
-
loop = asyncio.new_event_loop()
|
| 397 |
-
asyncio.set_event_loop(loop)
|
| 398 |
-
|
| 399 |
-
try:
|
| 400 |
-
async def stream_generator():
|
| 401 |
-
async_gen = fairies_client.chat_stream(agent_id, user_message, model)
|
| 402 |
-
try:
|
| 403 |
-
async for chunk in async_gen:
|
| 404 |
-
if "error" in chunk:
|
| 405 |
-
yield f"data: {json.dumps({'error': chunk['error']})}\n\n"
|
| 406 |
-
return
|
| 407 |
-
elif "delta" in chunk:
|
| 408 |
-
delta_response = {
|
| 409 |
-
"id": f"chatcmpl-{str(uuid.uuid4())}",
|
| 410 |
-
"object": "chat.completion.chunk",
|
| 411 |
-
"created": int(time.time()),
|
| 412 |
-
"model": model,
|
| 413 |
-
"choices": [{
|
| 414 |
-
"index": 0,
|
| 415 |
-
"delta": {"content": chunk["delta"]},
|
| 416 |
-
"finish_reason": None
|
| 417 |
-
}]
|
| 418 |
-
}
|
| 419 |
-
yield f"data: {json.dumps(delta_response)}\n\n"
|
| 420 |
-
elif chunk.get("done"):
|
| 421 |
-
final_response = {
|
| 422 |
-
"id": f"chatcmpl-{str(uuid.uuid4())}",
|
| 423 |
-
"object": "chat.completion.chunk",
|
| 424 |
-
"created": int(time.time()),
|
| 425 |
-
"model": model,
|
| 426 |
-
"choices": [{
|
| 427 |
-
"index": 0,
|
| 428 |
-
"delta": {},
|
| 429 |
-
"finish_reason": "stop"
|
| 430 |
-
}]
|
| 431 |
-
}
|
| 432 |
-
yield f"data: {json.dumps(final_response)}\n\n"
|
| 433 |
-
yield "data: [DONE]\n\n"
|
| 434 |
-
return
|
| 435 |
-
except asyncio.CancelledError:
|
| 436 |
-
print("⚠️ 流式传输被取消")
|
| 437 |
-
raise
|
| 438 |
-
except Exception as stream_error:
|
| 439 |
-
print(f"⚠️ 流式传输出错: {stream_error}")
|
| 440 |
-
yield f"data: {json.dumps({'error': str(stream_error)})}\n\n"
|
| 441 |
-
finally:
|
| 442 |
-
# 确保异步生成器正确关闭
|
| 443 |
-
if hasattr(async_gen, 'aclose'):
|
| 444 |
-
await async_gen.aclose()
|
| 445 |
-
|
| 446 |
-
# 改进的流式处理
|
| 447 |
-
async_gen = stream_generator()
|
| 448 |
-
try:
|
| 449 |
-
while True:
|
| 450 |
-
try:
|
| 451 |
-
chunk = loop.run_until_complete(async_gen.__anext__())
|
| 452 |
-
yield chunk
|
| 453 |
-
except StopAsyncIteration:
|
| 454 |
-
break
|
| 455 |
-
finally:
|
| 456 |
-
# 确保生成器正确关闭
|
| 457 |
-
try:
|
| 458 |
-
loop.run_until_complete(async_gen.aclose())
|
| 459 |
-
except Exception as gen_close_error:
|
| 460 |
-
print(f"⚠️ 关闭流生成器时出错: {gen_close_error}")
|
| 461 |
-
|
| 462 |
-
finally:
|
| 463 |
-
loop.close()
|
| 464 |
-
|
| 465 |
-
return Response(generate_stream(), mimetype='text/plain; charset=utf-8')
|
| 466 |
-
else:
|
| 467 |
-
# 非流式响应
|
| 468 |
-
loop = asyncio.new_event_loop()
|
| 469 |
-
asyncio.set_event_loop(loop)
|
| 470 |
-
|
| 471 |
-
response_content = ""
|
| 472 |
-
async def collect_response():
|
| 473 |
-
nonlocal response_content
|
| 474 |
-
async_gen = fairies_client.chat_stream(agent_id, user_message, model)
|
| 475 |
-
try:
|
| 476 |
-
async for chunk in async_gen:
|
| 477 |
-
if "error" in chunk:
|
| 478 |
-
return {"error": chunk["error"]}
|
| 479 |
-
elif "delta" in chunk:
|
| 480 |
-
response_content += chunk["delta"]
|
| 481 |
-
elif chunk.get("done"):
|
| 482 |
-
break
|
| 483 |
-
finally:
|
| 484 |
-
# 确保异步生成器正确关闭
|
| 485 |
-
if hasattr(async_gen, 'aclose'):
|
| 486 |
-
await async_gen.aclose()
|
| 487 |
-
return {"response": response_content}
|
| 488 |
-
|
| 489 |
-
result = loop.run_until_complete(collect_response())
|
| 490 |
-
loop.close()
|
| 491 |
-
|
| 492 |
-
if "error" in result:
|
| 493 |
-
return jsonify({"error": {"message": result["error"], "type": "internal_server_error"}}), 500
|
| 494 |
-
|
| 495 |
-
response = {
|
| 496 |
-
"id": f"chatcmpl-{str(uuid.uuid4())}",
|
| 497 |
-
"object": "chat.completion",
|
| 498 |
-
"created": int(time.time()),
|
| 499 |
-
"model": model,
|
| 500 |
-
"choices": [{
|
| 501 |
-
"index": 0,
|
| 502 |
-
"message": {
|
| 503 |
-
"role": "assistant",
|
| 504 |
-
"content": response_content
|
| 505 |
-
},
|
| 506 |
-
"finish_reason": "stop"
|
| 507 |
-
}],
|
| 508 |
-
"usage": {
|
| 509 |
-
"prompt_tokens": len(user_message.split()),
|
| 510 |
-
"completion_tokens": len(response_content.split()),
|
| 511 |
-
"total_tokens": len(user_message.split()) + len(response_content.split())
|
| 512 |
-
}
|
| 513 |
-
}
|
| 514 |
-
|
| 515 |
-
return jsonify(response)
|
| 516 |
-
|
| 517 |
-
except Exception as e:
|
| 518 |
-
print(f"❌ 处理请求时出错: {e}")
|
| 519 |
-
return jsonify({"error": {"message": str(e), "type": "internal_server_error"}}), 500
|
| 520 |
-
|
| 521 |
-
@app.route('/v1/tasks', methods=['GET'])
|
| 522 |
-
def get_tasks():
|
| 523 |
-
"""获取任务列表"""
|
| 524 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 525 |
-
if not is_valid:
|
| 526 |
-
return error_response, status_code
|
| 527 |
-
|
| 528 |
-
try:
|
| 529 |
-
tasks = fairies_client.list_tasks()
|
| 530 |
-
return jsonify({"tasks": tasks})
|
| 531 |
-
except Exception as e:
|
| 532 |
-
return jsonify({"error": {"message": str(e), "type": "internal_server_error"}}), 500
|
| 533 |
-
|
| 534 |
-
@app.route('/v1/tasks/<task_id>/status', methods=['PUT'])
|
| 535 |
-
def set_task_status(task_id):
|
| 536 |
-
"""更新任务状态"""
|
| 537 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 538 |
-
if not is_valid:
|
| 539 |
-
return error_response, status_code
|
| 540 |
-
|
| 541 |
-
data = request.get_json()
|
| 542 |
-
if not data or 'status' not in data:
|
| 543 |
-
return jsonify({"error": {"message": "status field is required", "type": "invalid_request_error"}}), 400
|
| 544 |
-
|
| 545 |
-
try:
|
| 546 |
-
success = fairies_client.update_task_status(task_id, data['status'])
|
| 547 |
-
if success:
|
| 548 |
-
return jsonify({"success": True, "message": f"Task {task_id} status updated"})
|
| 549 |
-
else:
|
| 550 |
-
return jsonify({"error": {"message": "Failed to update task status", "type": "internal_server_error"}}), 500
|
| 551 |
-
except Exception as e:
|
| 552 |
-
return jsonify({"error": {"message": str(e), "type": "internal_server_error"}}), 500
|
| 553 |
-
|
| 554 |
-
@app.route('/v1/tasks/<task_id>', methods=['DELETE'])
|
| 555 |
-
def remove_task(task_id):
|
| 556 |
-
"""删除任务"""
|
| 557 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 558 |
-
if not is_valid:
|
| 559 |
-
return error_response, status_code
|
| 560 |
-
|
| 561 |
-
try:
|
| 562 |
-
success = fairies_client.delete_task(task_id)
|
| 563 |
-
if success:
|
| 564 |
-
return jsonify({"success": True, "message": f"Task {task_id} deleted"})
|
| 565 |
-
else:
|
| 566 |
-
return jsonify({"error": {"message": "Failed to delete task", "type": "internal_server_error"}}), 500
|
| 567 |
-
except Exception as e:
|
| 568 |
-
return jsonify({"error": {"message": str(e), "type": "internal_server_error"}}), 500
|
| 569 |
-
|
| 570 |
-
@app.route('/v1/pool/status', methods=['GET'])
|
| 571 |
-
def pool_status():
|
| 572 |
-
"""获取连接池状态"""
|
| 573 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 574 |
-
if not is_valid:
|
| 575 |
-
return error_response, status_code
|
| 576 |
-
|
| 577 |
-
# 移除WebSocket连接池的清理逻辑
|
| 578 |
-
pool_info = {
|
| 579 |
-
"active_connections": 0,
|
| 580 |
-
"last_cleanup": 0,
|
| 581 |
-
"cleanup_interval": 0,
|
| 582 |
-
"next_cleanup_in": 0,
|
| 583 |
-
"connections": []
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
return jsonify(pool_info)
|
| 587 |
-
|
| 588 |
-
@app.route('/v1/pool/cleanup', methods=['POST'])
|
| 589 |
-
def manual_cleanup():
|
| 590 |
-
"""手动清理连接池"""
|
| 591 |
-
is_valid, error_response, status_code = verify_api_key()
|
| 592 |
-
if not is_valid:
|
| 593 |
-
return error_response, status_code
|
| 594 |
-
|
| 595 |
-
try:
|
| 596 |
-
loop = asyncio.new_event_loop()
|
| 597 |
-
asyncio.set_event_loop(loop)
|
| 598 |
-
# 移除WebSocket连接池的清理逻��
|
| 599 |
-
loop.close()
|
| 600 |
-
return jsonify({"success": True, "message": "Connection pool cleanup completed"})
|
| 601 |
-
except Exception as e:
|
| 602 |
-
return jsonify({"error": {"message": str(e), "type": "internal_server_error"}}), 500
|
| 603 |
-
|
| 604 |
-
@app.route('/health', methods=['GET'])
|
| 605 |
-
def health_check():
|
| 606 |
-
"""健康检查"""
|
| 607 |
-
try:
|
| 608 |
-
# 检查连接池状态
|
| 609 |
-
# 移除WebSocket连接池的清理逻辑
|
| 610 |
-
pool_size = 0
|
| 611 |
-
|
| 612 |
-
# 检查Cookie配置
|
| 613 |
-
cookie_status = "✅ 已设置" if FAIRIES_COOKIE_STRING else "❌ 未设置"
|
| 614 |
-
|
| 615 |
-
health_info = {
|
| 616 |
-
"status": "healthy",
|
| 617 |
-
"service": "Fairies AI OpenAI-Compatible Proxy",
|
| 618 |
-
"version": "2.0",
|
| 619 |
-
"websocket_pool_size": pool_size,
|
| 620 |
-
"cookie_status": cookie_status,
|
| 621 |
-
"timestamp": datetime.now().isoformat()
|
| 622 |
-
}
|
| 623 |
-
return jsonify(health_info)
|
| 624 |
-
except Exception as e:
|
| 625 |
-
return jsonify({
|
| 626 |
-
"status": "unhealthy",
|
| 627 |
-
"error": str(e),
|
| 628 |
-
"timestamp": datetime.now().isoformat()
|
| 629 |
-
}), 500
|
| 630 |
-
|
| 631 |
-
@app.route('/', methods=['GET'])
|
| 632 |
-
def api_info():
|
| 633 |
-
return jsonify({
|
| 634 |
-
"service": "Fairies AI OpenAI-Compatible Proxy v2.0",
|
| 635 |
-
"base_url": f"http://localhost:{PORT}",
|
| 636 |
-
"endpoints": [
|
| 637 |
-
"/v1/chat/completions",
|
| 638 |
-
"/v1/models",
|
| 639 |
-
"/v1/tasks",
|
| 640 |
-
"/health"
|
| 641 |
-
],
|
| 642 |
-
"features": [
|
| 643 |
-
"OpenAI兼容的聊天接口",
|
| 644 |
-
"智能WebSocket连接池管理",
|
| 645 |
-
"流式响应支持",
|
| 646 |
-
"预配置Agent ID",
|
| 647 |
-
"完善的错误处理机制"
|
| 648 |
-
]
|
| 649 |
-
})
|
| 650 |
-
|
| 651 |
-
def cleanup_on_exit():
|
| 652 |
-
"""程序退出时的清理函数"""
|
| 653 |
-
print("\n🔧 正在清理资源...")
|
| 654 |
-
try:
|
| 655 |
-
# 移除WebSocket连接池的清理逻辑
|
| 656 |
-
print("✅ 资源清理完成 (无WebSocket连接池需要清理)")
|
| 657 |
-
except Exception as e:
|
| 658 |
-
print(f"⚠️ 清理资源时出错: {e}")
|
| 659 |
-
|
| 660 |
-
def signal_handler(signum, frame):
|
| 661 |
-
"""信号处理函数"""
|
| 662 |
-
print(f"\n📡 收到信号 {signum},正在关闭服务...")
|
| 663 |
-
cleanup_on_exit()
|
| 664 |
-
import sys
|
| 665 |
-
sys.exit(0)
|
| 666 |
-
|
| 667 |
-
if __name__ == '__main__':
|
| 668 |
-
# 注册清理函数和信号处理
|
| 669 |
-
import atexit
|
| 670 |
-
atexit.register(cleanup_on_exit)
|
| 671 |
-
signal.signal(signal.SIGINT, signal_handler)
|
| 672 |
-
signal.signal(signal.SIGTERM, signal_handler)
|
| 673 |
-
|
| 674 |
-
print("🚀 Fairies AI OpenAI-Compatible Proxy v2.0 启动中...")
|
| 675 |
-
print(f"📋 服务地址: http://localhost:{PORT}")
|
| 676 |
-
print(f"🔑 API密钥: {LOCAL_API_KEY}")
|
| 677 |
-
print(f"📦 支持模型: {len(AVAILABLE_MODELS)} 个")
|
| 678 |
-
print()
|
| 679 |
-
|
| 680 |
-
# 验证连接配置
|
| 681 |
-
print("🔍 验证连接配置...")
|
| 682 |
-
if not fairies_client.validate_connection():
|
| 683 |
-
print("⚠️ 配置验证失败,服务可能无法正常工作")
|
| 684 |
-
print("💡 请设置正确的环境变量后重启服务")
|
| 685 |
-
print()
|
| 686 |
-
|
| 687 |
-
print("🔧 测试命令:")
|
| 688 |
-
print(f"curl -X POST http://localhost:{PORT}/v1/chat/completions \\")
|
| 689 |
-
print(f" -H 'Authorization: Bearer {LOCAL_API_KEY}' \\")
|
| 690 |
-
print(" -H 'Content-Type: application/json' \\")
|
| 691 |
-
print(" -d '{\"messages\": [{\"role\": \"user\", \"content\": \"你好\"}]}'")
|
| 692 |
-
print()
|
| 693 |
-
print("📋 任务管理:")
|
| 694 |
-
print(f"curl -X GET http://localhost:{PORT}/v1/tasks \\")
|
| 695 |
-
print(f" -H 'Authorization: Bearer {LOCAL_API_KEY}'")
|
| 696 |
-
print()
|
| 697 |
-
print("🏥 健康检查:")
|
| 698 |
-
print(f"curl -X GET http://localhost:{PORT}/health")
|
| 699 |
-
print()
|
| 700 |
-
|
| 701 |
-
try:
|
| 702 |
-
print("🚀 使用Waitress服务器启动...")
|
| 703 |
-
serve(app, host='0.0.0.0', port=PORT)
|
| 704 |
-
except ImportError:
|
| 705 |
-
# 如果没有安装waitress,回退到Flask开发服务器
|
| 706 |
-
print("⚠️ 未找到Waitress,使用Flask开发服务器启动...")
|
| 707 |
-
app.run(host='0.0.0.0', port=PORT, debug=False)
|
| 708 |
-
except KeyboardInterrupt:
|
| 709 |
-
print("\n👋 服务被用户中断")
|
| 710 |
-
except Exception as e:
|
| 711 |
-
print(f"\n❌ 服务运行出错: {e}")
|
| 712 |
-
finally:
|
| 713 |
-
cleanup_on_exit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|