s21s2 commited on
Commit
9c5ff52
·
verified ·
1 Parent(s): 99c5b3f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +408 -0
app.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request, HTTPException, Header
2
+ from fastapi.responses import StreamingResponse
3
+ import httpx
4
+ import json
5
+ import uuid
6
+ import time
7
+ import logging
8
+ import random
9
+ from typing import List, Dict, Any, AsyncGenerator
10
+ from functools import wraps
11
+ from pydantic import BaseModel, Field
12
+
13
+ app = FastAPI()
14
+
15
+ # Configure logging
16
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class OpenAIMessage(BaseModel):
21
+ role: str
22
+ content: str | Dict[str, Any] | List[Any] = Field(default="") # 支持多种类型
23
+
24
+
25
+ class OpenAIRequest(BaseModel):
26
+ messages: List[OpenAIMessage]
27
+ stream: bool = Field(default=False)
28
+
29
+
30
+ class WoHistoryItem(BaseModel):
31
+ query: str
32
+ rewriteQuery: str
33
+ uploadFileUrl: str
34
+ response: str
35
+ reasoningContent: str
36
+ state: str
37
+ key: str
38
+
39
+
40
+ class WoRequest(BaseModel):
41
+ modelId: int
42
+ input: str
43
+ history: List[WoHistoryItem]
44
+
45
+
46
+ def validate_messages(f):
47
+ """Message format validation decorator"""
48
+
49
+ @wraps(f)
50
+ async def decorated_function(*args, **kwargs):
51
+ request: Request = kwargs.get('request') # type: ignore
52
+ if not request:
53
+ raise HTTPException(status_code=500, detail="Missing request object")
54
+ body = await request.json()
55
+ messages = body.get('messages', [])
56
+
57
+ for msg in messages:
58
+ if 'role' not in msg or 'content' not in msg:
59
+ raise HTTPException(status_code=400, detail="Invalid message format")
60
+ return await f(*args, **kwargs)
61
+
62
+ return decorated_function
63
+
64
+
65
+ def convert_history(messages: List[Dict[str, str]]) -> List[Dict[str, Any]]:
66
+ """Convert OpenAI format messages to WoCloud format"""
67
+ history = []
68
+
69
+ for i in range(len(messages) - 1):
70
+ try:
71
+ if messages[i]['role'] == 'user' and i + 1 < len(messages) and messages[i + 1]['role'] == 'assistant':
72
+ query = messages[i]['content']
73
+ response = messages[i + 1]['content']
74
+
75
+ # Convert JSON content to string if necessary
76
+ if not isinstance(query, str):
77
+ query = json.dumps(query)
78
+ if not isinstance(response, str):
79
+ response = json.dumps(response)
80
+
81
+ query = query.strip()
82
+ response = response.strip()
83
+
84
+ history.append({
85
+ "query": query,
86
+ "rewriteQuery": query,
87
+ "uploadFileUrl": "",
88
+ "response": response,
89
+ "reasoningContent": "",
90
+ "state": "finish",
91
+ "key": str(random.random())
92
+ })
93
+ except (KeyError, IndexError) as e:
94
+ logger.warning(f"Error processing message: {str(e)}")
95
+ continue
96
+
97
+ logger.debug(f"Converted history: {json.dumps(history, ensure_ascii=False)}")
98
+ return history
99
+
100
+
101
+ async def handle_wo_error(response: httpx.Response) -> Dict[str, str]:
102
+ """Unified handling of WoCloud error responses"""
103
+ try:
104
+ if response.headers.get('Content-Type', '').startswith('text/event-stream'):
105
+ async for line in response.aiter_lines():
106
+ if line.startswith('data:'):
107
+ try:
108
+ error_data = json.loads(line[5:].strip())
109
+ return {
110
+ "code": error_data.get('code'),
111
+ "message": error_data.get('message', 'Unknown error')
112
+ }
113
+ except json.JSONDecodeError:
114
+ return {
115
+ "code": "PARSE_ERROR",
116
+ "message": f"Invalid JSON in error response: {line[5:100]}"
117
+ }
118
+ else:
119
+ try:
120
+ error_data = response.json()
121
+ return {
122
+ "code": error_data.get('code'),
123
+ "message": error_data.get('message', error_data.get('response', 'Unknown error'))
124
+ }
125
+ except json.JSONDecodeError:
126
+ return {
127
+ "code": "PARSE_ERROR",
128
+ "message": f"Invalid JSON in error response: {response.text[:100]}"
129
+ }
130
+ except Exception as e:
131
+ return {
132
+ "code": "PARSE_ERROR",
133
+ "message": f"Failed to parse error response: {str(e)}"
134
+ }
135
+
136
+
137
+ def create_chunk(content: str, response_id: str, created_time: int, finish_reason: str = None,
138
+ is_start: bool = False, role: str = None) -> str:
139
+ """Creates a single chunk in the OpenAI streaming format."""
140
+ chunk_data = {
141
+ 'id': response_id,
142
+ 'object': 'chat.completion.chunk',
143
+ 'created': created_time,
144
+ 'model': 'DeepSeek-R1',
145
+ 'choices': [{
146
+ 'index': 0,
147
+ 'delta': {},
148
+ 'finish_reason': finish_reason
149
+ }]
150
+ }
151
+ if is_start and role:
152
+ chunk_data["choices"][0]["delta"]["role"] = role
153
+ elif content:
154
+ chunk_data['choices'][0]['delta']['content'] = content
155
+
156
+
157
+ return f"data: {json.dumps(chunk_data)}\n\n"
158
+
159
+
160
+ @app.post('/v1/chat/completions')
161
+ @validate_messages
162
+ async def chat_completions(request: Request, authorization: str = Header(...)):
163
+ if not authorization.startswith('Bearer '):
164
+ raise HTTPException(status_code=401, detail="Invalid Authorization header format")
165
+
166
+ access_token = authorization[7:]
167
+ openai_request = await request.json()
168
+ stream_mode = openai_request.get('stream', False)
169
+
170
+ # Extract last user message
171
+ user_message = next(
172
+ (msg['content'] for msg in reversed(openai_request['messages'])
173
+ if msg['role'] == 'user' and msg.get('content')),
174
+ None
175
+ )
176
+ if not user_message:
177
+ raise HTTPException(status_code=400, detail="No valid user message found")
178
+
179
+ # Convert JSON content to string if necessary
180
+ if not isinstance(user_message, str):
181
+ user_message = json.dumps(user_message)
182
+
183
+ # Build WoCloud request
184
+ wo_headers = {
185
+ 'content-type': 'application/json',
186
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
187
+ 'connection': 'keep-alive',
188
+ 'dnt': '1',
189
+ 'origin': 'https://panservice.mail.wo.cn',
190
+ 'referer': f'https://panservice.mail.wo.cn/h5/wocloud_ai/?token={access_token}&modelType=1&platform=yunpanWeb&clientId=1001000021',
191
+ 'sec-fetch-dest': 'empty',
192
+ 'sec-fetch-mode': 'cors',
193
+ 'sec-fetch-site': 'same-origin',
194
+ 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1 Edg/134.0.0.0',
195
+ 'x-yp-access-token': access_token,
196
+ 'x-yp-app-version': '', # 注意保持空值
197
+ 'x-yp-client-id': '1001000021', # 原值为1001000035
198
+ 'accept': 'text/event-stream' if stream_mode else 'application/json'
199
+ }
200
+
201
+ # Convert history
202
+ history = convert_history(openai_request['messages'][:-1]) # Exclude last user message
203
+ wo_data = {
204
+ "modelId": 1,
205
+ "input": user_message,
206
+ "history": history
207
+ }
208
+ logger.debug(f"Forwarding request to WoCloud: {json.dumps(wo_data, ensure_ascii=False)}")
209
+
210
+ async def stream_response() -> AsyncGenerator[bytes, None]:
211
+ response_id = f"chatcmpl-{uuid.uuid4()}"
212
+ created_time = int(time.time())
213
+ full_content = ""
214
+
215
+ async with httpx.AsyncClient() as client:
216
+ try:
217
+ async with client.stream(
218
+ 'POST',
219
+ 'https://panservice.mail.wo.cn/wohome/ai/assistant/query',
220
+ headers=wo_headers,
221
+ json=wo_data,
222
+ timeout=30
223
+ ) as response:
224
+
225
+ logger.debug(f"WoCloud stream response status: {response.status_code}")
226
+
227
+ if response.status_code != 200:
228
+ error_info = await handle_wo_error(response)
229
+ logger.error(f"WoCloud API error: {error_info}")
230
+ yield f"data: {json.dumps({'error': error_info})}\n\n".encode('utf-8')
231
+ return
232
+
233
+ # Send initial chunk with role
234
+ yield create_chunk("", response_id, created_time, is_start=True, role="assistant").encode("utf-8")
235
+
236
+ async for line in response.aiter_lines():
237
+ if line:
238
+ try:
239
+ if line.startswith('data:'):
240
+ data = json.loads(line[5:])
241
+
242
+ if data.get('code') and data['code'] != 0 and data['code'] != '0':
243
+ logger.error(f"WoCloud stream error: {data}")
244
+ yield f"data: {json.dumps({'error': {'code': data['code'], 'message': data.get('message', 'Unknown error')}})}\n\n".encode(
245
+ 'utf-8')
246
+ return
247
+
248
+ content = data.get('response', '')
249
+ reasoning = data.get('reasoningContent', '')
250
+
251
+ if reasoning:
252
+ if not full_content:
253
+ yield create_chunk("<think>\n", response_id, created_time).encode("utf-8")
254
+
255
+ full_content += reasoning
256
+ yield create_chunk(reasoning, response_id, created_time).encode("utf-8")
257
+
258
+
259
+ if content:
260
+ if full_content: # If there's thinking content, end thinking first
261
+ yield create_chunk("\n</think>\n\n", response_id, created_time).encode("utf-8")
262
+ full_content = ""
263
+ yield create_chunk(content, response_id, created_time).encode("utf-8")
264
+
265
+
266
+ if data.get("finish") == 1:
267
+ break
268
+
269
+ except Exception as e:
270
+ logger.error(f"Stream parsing error: {str(e)}")
271
+ yield f"data: {json.dumps({'error': {'code': 'PARSE_ERROR', 'message': str(e)}})}\n\n".encode(
272
+ 'utf-8')
273
+ except httpx.RequestError as e:
274
+ logger.error(f"Request failed: {str(e)}")
275
+ yield f"data: {json.dumps({'error': {'code': 'CONNECTION_ERROR', 'message': str(e)}})}\n\n".encode(
276
+ 'utf-8')
277
+
278
+ yield create_chunk("", response_id, created_time, finish_reason="stop").encode("utf-8")
279
+ yield "data: [DONE]\n\n".encode('utf-8')
280
+
281
+ if stream_mode:
282
+ return StreamingResponse(stream_response(), media_type="text/event-stream")
283
+
284
+ else:
285
+ async with httpx.AsyncClient() as client:
286
+ try:
287
+ response = await client.post(
288
+ 'https://panservice.mail.wo.cn/wohome/ai/assistant/query',
289
+ headers=wo_headers,
290
+ json=wo_data,
291
+ timeout=30
292
+ )
293
+ logger.debug(f"WoCloud response status: {response.status_code}")
294
+ logger.debug(f"WoCloud response headers: {response.headers}")
295
+ if response.status_code != 200:
296
+ error_info = await handle_wo_error(response)
297
+ logger.error(f"WoCloud API error: {error_info}")
298
+ raise HTTPException(status_code=502, detail=error_info)
299
+
300
+ content = ""
301
+
302
+ if response.headers.get('Content-Type', '').startswith('application/json'):
303
+ try:
304
+ response_data = response.json()
305
+ if response_data.get('code') and response_data['code'] != 0 and response_data['code'] != '0':
306
+ logger.error(f"WoCloud API error: {response_data}")
307
+ raise HTTPException(status_code=502, detail={
308
+ "code": response_data['code'],
309
+ "message": response_data.get("message", "Unknown error")
310
+ })
311
+
312
+ content = response_data.get('response', '')
313
+ reasoning = response_data.get('reasoningContent', '')
314
+
315
+ final_content = ""
316
+ if reasoning:
317
+ final_content += f"<think>\n{reasoning}\n</think>\n\n"
318
+ final_content += content
319
+ except json.JSONDecodeError:
320
+ logger.error(f"Invalid JSON Response:{response.text[:200]}")
321
+ raise HTTPException(status_code=502,
322
+ detail={"code": "PARSE_ERROR", "message": "Failed to parse JSON response"})
323
+ else:
324
+ response_data = {"response": "", "reasoningContent": ""}
325
+ try:
326
+ for line in response.text.split('\n'):
327
+ if line.startswith('data:'):
328
+ try:
329
+ data = json.loads(line[5:])
330
+ if data.get('code') and data['code'] != 0 and data['code'] != '0':
331
+ logger.error(f"WoCloud API error in stream:{data}")
332
+ raise HTTPException(status_code=502, detail={
333
+ "code": data['code'],
334
+ "message": data.get('message', 'Unknown error')
335
+ })
336
+ response_data['response'] += data.get('response', '')
337
+ response_data['reasoningContent'] += data.get('reasoningContent', '')
338
+ except json.JSONDecodeError:
339
+ logger.warning(f"Invalid JSON in stream line: {line[:100]}")
340
+ final_content = ''
341
+ if response_data['reasoningContent']:
342
+ final_content += f"<think>\n{response_data['reasoningContent']}\n</think>\n\n"
343
+ final_content += response_data['response']
344
+
345
+ except Exception as e:
346
+ logger.error(f"Error parsing stream response:{str(e)}")
347
+ raise HTTPException(status_code=502, detail={
348
+ "code": "PARSE_ERROR",
349
+ "message": f"Failed to parse stream response: {str(e)}"
350
+ })
351
+
352
+ if not final_content:
353
+ logger.warning("Empty content in response")
354
+ final_content = "抱歉,没有收到有效的回复。"
355
+
356
+ return {
357
+ "id": f"chatcmpl-{uuid.uuid4()}",
358
+ "object": "chat.completion",
359
+ "created": int(time.time()),
360
+ "model": "DeepSeek-R1",
361
+ "choices": [{
362
+ "index": 0,
363
+ "message": {
364
+ "role": "assistant",
365
+ "content": final_content
366
+ },
367
+ "finish_reason": "stop"
368
+ }],
369
+ "usage": {
370
+ "prompt_tokens": len(user_message),
371
+ "completion_tokens": len(final_content),
372
+ "total_tokens": len(user_message) + len(final_content)
373
+ }
374
+ }
375
+
376
+
377
+ except httpx.RequestError as e:
378
+ logger.error(f"Request failed: {str(e)}")
379
+ raise HTTPException(status_code=502, detail={
380
+ "code": "NETWORK_ERROR",
381
+ "message": str(e)
382
+ })
383
+ except Exception as e:
384
+ logger.exception("Unexpected error")
385
+ raise HTTPException(status_code=500, detail={
386
+ "code": "INTERNAL_ERROR",
387
+ "message": str(e)
388
+ })
389
+
390
+
391
+ @app.get('/v1/models')
392
+ async def list_models():
393
+ return {
394
+ "object": "list",
395
+ "data": [{
396
+ "id": "DeepSeek-R1",
397
+ "object": "model",
398
+ "created": int(time.time()),
399
+ "owned_by": "ChinaUnicom",
400
+ "capabilities": ["chat", "completions"]
401
+ }]
402
+ }
403
+
404
+
405
+ if __name__ == '__main__':
406
+ import uvicorn
407
+
408
+ uvicorn.run(app, host="0.0.0.0", port=8080)