heiyuheiyu commited on
Commit
6d03778
·
verified ·
1 Parent(s): a250472

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +463 -0
app.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Claude Web API Proxy - HuggingFace Spaces with ReAct
3
+ """
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.responses import StreamingResponse, JSONResponse
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from contextlib import asynccontextmanager
8
+ import httpx
9
+ import json
10
+ import asyncio
11
+ from datetime import datetime
12
+ from typing import Optional, Dict
13
+ import os
14
+ import uuid
15
+ import re
16
+ import pickle
17
+ from pathlib import Path
18
+ import logging
19
+
20
+ # 日志配置
21
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # 重试函数
25
+ async def retry_async(fn, max_retries=3, delay=1.0):
26
+ for i in range(max_retries):
27
+ try:
28
+ return await fn()
29
+ except Exception as e:
30
+ if i == max_retries - 1:
31
+ raise
32
+ logger.warning(f"Retry {i+1}/{max_retries}: {e}")
33
+ await asyncio.sleep(delay * (i + 1))
34
+
35
+ # 文件持久化
36
+ CACHE_FILE = Path("/tmp/conversation_cache.pkl")
37
+ conversation_cache: Dict[str, str] = {}
38
+
39
+ # 请求记录(内存)
40
+ request_log: list = []
41
+
42
+ def load_cache():
43
+ global conversation_cache
44
+ if CACHE_FILE.exists():
45
+ try:
46
+ with open(CACHE_FILE, 'rb') as f:
47
+ conversation_cache = pickle.load(f)
48
+ except:
49
+ conversation_cache = {}
50
+
51
+ def save_cache():
52
+ try:
53
+ with open(CACHE_FILE, 'wb') as f:
54
+ pickle.dump(conversation_cache, f)
55
+ except:
56
+ pass
57
+
58
+ # 智能保活
59
+ last_request_time = datetime.now()
60
+
61
+ async def keep_alive():
62
+ while True:
63
+ idle_time = (datetime.now() - last_request_time).total_seconds()
64
+ if idle_time < 3600:
65
+ await asyncio.sleep(1800)
66
+ else:
67
+ await asyncio.sleep(6 * 3600)
68
+ try:
69
+ async with httpx.AsyncClient() as client:
70
+ url = os.getenv("SPACE_URL", "")
71
+ if url:
72
+ await client.get(f"{url}/health")
73
+ except:
74
+ pass
75
+
76
+ @asynccontextmanager
77
+ async def lifespan(app: FastAPI):
78
+ load_cache()
79
+ logger.info("Application started")
80
+ yield
81
+ logger.info("Application shutdown")
82
+
83
+ app = FastAPI(title="Claude Proxy", lifespan=lifespan)
84
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
85
+
86
+ @app.get("/clear_cache")
87
+ async def clear_cache():
88
+ global conversation_cache
89
+ conversation_cache = {}
90
+ if CACHE_FILE.exists():
91
+ CACHE_FILE.unlink()
92
+ return {"status": "cache cleared"}
93
+
94
+ @app.get("/logs")
95
+ async def get_logs():
96
+ """获取最近的日志"""
97
+ try:
98
+ import subprocess
99
+ result = subprocess.run(['tail', '-n', '100', '/proc/1/fd/1'],
100
+ capture_output=True, text=True, timeout=5)
101
+ return {"logs": result.stdout}
102
+ except:
103
+ return {"error": "Cannot read logs"}
104
+
105
+ @app.get("/reqlog")
106
+ async def req_log():
107
+ """获取请求记录,用于排查哪些客户端发了请求"""
108
+ return {"count": len(request_log), "requests": request_log}
109
+
110
+ @app.get("/health")
111
+ async def health():
112
+ return {"status": "ok", "time": datetime.now().isoformat()}
113
+
114
+ @app.get("/")
115
+ async def root():
116
+ return {"name": "Claude Proxy", "version": "2.0-hf-react"}
117
+
118
+ async def get_org_id(key: str) -> Optional[str]:
119
+ logger.info("[3] Space → Claude REQUEST: GET /api/organizations")
120
+ headers = {
121
+ 'Cookie': f'sessionKey={key[:20]}...',
122
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
123
+ 'Accept': 'application/json',
124
+ 'Accept-Language': 'en-US,en;q=0.9',
125
+ 'Referer': 'https://claude.ai/chats',
126
+ 'Origin': 'https://claude.ai',
127
+ 'Sec-Fetch-Dest': 'empty',
128
+ 'Sec-Fetch-Mode': 'cors',
129
+ 'Sec-Fetch-Site': 'same-origin'
130
+ }
131
+ async with httpx.AsyncClient() as client:
132
+ r = await client.get('https://claude.ai/api/organizations',
133
+ headers={**headers, 'Cookie': f'sessionKey={key}'}, timeout=30.0)
134
+ logger.info(f"[4] Claude → Space RESPONSE: status={r.status_code}, body={r.text[:200]}")
135
+ if r.status_code == 200:
136
+ data = r.json()
137
+ return data[0]['uuid'] if data else None
138
+ else:
139
+ raise Exception(f"Claude.ai returned {r.status_code}")
140
+
141
+ async def create_conv(key: str, org_id: str) -> Optional[str]:
142
+ conv_id = str(uuid.uuid4())
143
+ headers = {
144
+ 'Content-Type': 'application/json',
145
+ 'Cookie': f'sessionKey={key}',
146
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
147
+ 'Accept': 'application/json',
148
+ 'Accept-Language': 'en-US,en;q=0.9',
149
+ 'Referer': 'https://claude.ai/chats',
150
+ 'Origin': 'https://claude.ai',
151
+ 'Sec-Fetch-Dest': 'empty',
152
+ 'Sec-Fetch-Mode': 'cors',
153
+ 'Sec-Fetch-Site': 'same-origin'
154
+ }
155
+ async with httpx.AsyncClient() as client:
156
+ r = await client.post(f'https://claude.ai/api/organizations/{org_id}/chat_conversations',
157
+ headers=headers,
158
+ json={
159
+ 'uuid': conv_id,
160
+ 'name': '',
161
+ 'include_conversation_preferences': True,
162
+ 'is_temporary': False,
163
+ 'enabled_imagine': False
164
+ }, timeout=30.0)
165
+ logger.info(f"create_conv response: status={r.status_code}, body={r.text[:500]}")
166
+ if r.status_code in [200, 201]:
167
+ return conv_id
168
+ else:
169
+ raise Exception(f"Failed to create conversation: {r.status_code}")
170
+
171
+ def extract_prompt(messages):
172
+ result = []
173
+ for m in messages:
174
+ role = m['role'].title()
175
+ content = m['content']
176
+
177
+ # 支持图片
178
+ if isinstance(content, list):
179
+ parts = []
180
+ for part in content:
181
+ if part.get('type') == 'text':
182
+ parts.append(part['text'])
183
+ elif part.get('type') == 'image_url':
184
+ parts.append(f"[Image: {part['image_url']['url']}]")
185
+ result.append(f"{role}: {' '.join(parts)}")
186
+ else:
187
+ result.append(f"{role}: {content}")
188
+
189
+ return '\n\n'.join(result)
190
+
191
+ def extract_attachments(messages):
192
+ attachments = []
193
+ for m in messages:
194
+ if isinstance(m['content'], list):
195
+ for part in m['content']:
196
+ if part.get('type') == 'image_url':
197
+ attachments.append({
198
+ 'extracted_content': '',
199
+ 'file_name': 'image.png',
200
+ 'file_size': 0,
201
+ 'file_type': 'image/png',
202
+ 'url': part['image_url']['url']
203
+ })
204
+ return attachments
205
+
206
+ def enhance_prompt(prompt: str, messages: list) -> str:
207
+ has_system = any(m['role'] == 'system' for m in messages)
208
+ if not has_system:
209
+ return prompt
210
+ return prompt + "\n\n[Environment: OpenClaw | Tools: web_search, artifacts, repl available]"
211
+
212
+ def inject_react_prompt(prompt: str, tools: list) -> str:
213
+ if not tools:
214
+ return prompt
215
+ tool_defs = '\n\n'.join([f"{t.get('name', 'unknown')}: {t.get('description', '')}" for t in tools])
216
+ return prompt + f"""
217
+
218
+ [Tool Calling Protocol]
219
+ You have access to these tools:
220
+ {tool_defs}
221
+
222
+ Use this format:
223
+ Thought: [reasoning]
224
+ Action: [tool_name]
225
+ Action Input: [JSON]
226
+
227
+ After result:
228
+ Observation: [result]
229
+
230
+ When done:
231
+ Final Answer: [response]
232
+ """
233
+
234
+ def parse_react_output(text: str) -> Optional[dict]:
235
+ action_match = re.search(r'Action:\s*(\w+)', text)
236
+ input_match = re.search(r'Action Input:\s*(\{[^}]*\})', text)
237
+ if action_match and input_match:
238
+ try:
239
+ return {'type': 'tool_use', 'id': f'tool_{int(datetime.now().timestamp())}',
240
+ 'name': action_match.group(1), 'input': json.loads(input_match.group(1))}
241
+ except:
242
+ return None
243
+ return None
244
+
245
+ @app.post("/v1/chat/completions")
246
+ @app.post("/chat/completions")
247
+ async def chat(request: Request):
248
+ global last_request_time
249
+ last_request_time = datetime.now()
250
+ start_time = datetime.now()
251
+
252
+ try:
253
+ body = await request.json()
254
+ auth = request.headers.get('Authorization', '')
255
+
256
+ # 记录请求(用于排查客户端是否真正到达Space)
257
+ client_ip = request.client.host if request.client else "unknown"
258
+ user_agent = request.headers.get('User-Agent', 'unknown')
259
+ x_forwarded_for = request.headers.get('X-Forwarded-For', 'none')
260
+ request_log.append({
261
+ "time": datetime.now().isoformat(),
262
+ "ip": client_ip,
263
+ "x_forwarded_for": x_forwarded_for,
264
+ "user_agent": user_agent[:80],
265
+ "key_prefix": auth[:30],
266
+ "body_preview": json.dumps(body, ensure_ascii=False)[:150]
267
+ })
268
+ if len(request_log) > 30:
269
+ request_log.pop(0)
270
+
271
+ logger.info("="*50)
272
+ logger.info(f"[1] OpenClaw → Space REQUEST")
273
+ logger.info(f" IP: {client_ip}")
274
+ logger.info(f" User-Agent: {user_agent}")
275
+ logger.info(f" X-Forwarded-For: {x_forwarded_for}")
276
+ logger.info(f" Body: {json.dumps(body, ensure_ascii=False)[:500]}")
277
+ logger.info("="*50)
278
+
279
+ if not auth.startswith('Bearer '):
280
+ logger.info("[2] Space → OpenClaw RESPONSE: 401 Missing auth")
281
+ return JSONResponse({"error": {"message": "Missing auth", "type": "auth_error"}}, 401)
282
+
283
+ key = auth.replace('Bearer ', '')
284
+ logger.info(f"[3] Calling get_org_id with sessionKey={key[:20]}...")
285
+ org_id = await retry_async(lambda: get_org_id(key))
286
+ if not org_id:
287
+ logger.error(f"[2] Space → OpenClaw RESPONSE: 401 Invalid key")
288
+ return JSONResponse({"error": {"message": "Invalid key or network error", "type": "auth_error"}}, 401)
289
+
290
+ conv_id = conversation_cache.get(key)
291
+ if not conv_id:
292
+ conv_id = await retry_async(lambda: create_conv(key, org_id))
293
+ if conv_id:
294
+ conversation_cache[key] = conv_id
295
+ save_cache()
296
+ if not conv_id:
297
+ logger.error("Failed to create conversation")
298
+ return JSONResponse({"error": {"message": "Conv error", "type": "api_error"}}, 500)
299
+
300
+ messages = body.get('messages', [])
301
+ tools = body.get('tools', [])
302
+ prompt = extract_prompt(messages)
303
+ prompt = enhance_prompt(prompt, messages)
304
+ if tools:
305
+ prompt = inject_react_prompt(prompt, tools)
306
+
307
+ attachments = extract_attachments(messages)
308
+ model = body.get('model', 'claude-sonnet-4-6')
309
+ payload = {
310
+ 'prompt': prompt,
311
+ 'timezone': body.get('timezone', 'Asia/Shanghai'),
312
+ 'locale': body.get('locale', 'en-US'),
313
+ 'model': model,
314
+ 'rendering_mode': 'messages',
315
+ 'attachments': attachments,
316
+ 'files': [],
317
+ 'tools': [
318
+ {"type": "web_search_v0", "name": "web_search"},
319
+ {"type": "artifacts_v0", "name": "artifacts"},
320
+ {"type": "repl_v0", "name": "repl"}
321
+ ]
322
+ }
323
+
324
+ logger.info(f"Sending to Claude: attachments={len(attachments)}")
325
+ logger.info(f"Payload to Claude: {json.dumps(payload, ensure_ascii=False)[:1000]}")
326
+
327
+ stream = body.get('stream', True)
328
+
329
+ # 非流式响应
330
+ if not stream:
331
+ try:
332
+ full_text = ''
333
+ headers = {
334
+ 'Content-Type': 'application/json',
335
+ 'Cookie': f'sessionKey={key}',
336
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
337
+ 'Accept': 'text/event-stream',
338
+ 'Accept-Language': 'en-US,en;q=0.9',
339
+ 'Referer': 'https://claude.ai/chats',
340
+ 'Origin': 'https://claude.ai',
341
+ 'Sec-Fetch-Dest': 'empty',
342
+ 'Sec-Fetch-Mode': 'cors',
343
+ 'Sec-Fetch-Site': 'same-origin'
344
+ }
345
+ async with httpx.AsyncClient() as client:
346
+ async with client.stream('POST',
347
+ f'https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion',
348
+ headers=headers,
349
+ json=payload, timeout=120.0) as response:
350
+
351
+ async for line in response.aiter_lines():
352
+ if line.startswith('data: '):
353
+ try:
354
+ data = json.loads(line[6:])
355
+ if data.get('type') == 'content_block_delta' and data.get('delta', {}).get('text'):
356
+ full_text += data['delta']['text']
357
+ except:
358
+ pass
359
+
360
+ return JSONResponse({
361
+ "id": f"chatcmpl-{int(datetime.now().timestamp())}",
362
+ "object": "chat.completion",
363
+ "created": int(datetime.now().timestamp()),
364
+ "model": model,
365
+ "choices": [{
366
+ "index": 0,
367
+ "message": {"role": "assistant", "content": full_text},
368
+ "finish_reason": "stop"
369
+ }]
370
+ })
371
+ except Exception as e:
372
+ logger.error(f"Non-stream error: {e}")
373
+ return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
374
+
375
+ # 流式响应
376
+ async def generate():
377
+ try:
378
+ full_text = ''
379
+ headers = {
380
+ 'Content-Type': 'application/json',
381
+ 'Cookie': f'sessionKey={key}',
382
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
383
+ 'Accept': 'text/event-stream',
384
+ 'Accept-Language': 'en-US,en;q=0.9',
385
+ 'Referer': 'https://claude.ai/chats',
386
+ 'Origin': 'https://claude.ai',
387
+ 'Sec-Fetch-Dest': 'empty',
388
+ 'Sec-Fetch-Mode': 'cors',
389
+ 'Sec-Fetch-Site': 'same-origin'
390
+ }
391
+ async with httpx.AsyncClient() as client:
392
+ async with client.stream('POST',
393
+ f'https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion',
394
+ headers=headers,
395
+ json=payload, timeout=120.0) as response:
396
+
397
+ if response.status_code != 200:
398
+ error_body = await response.aread()
399
+ logger.error(f"Claude API error: status={response.status_code}, body={error_body.decode()[:500]}")
400
+ yield f'data: {json.dumps({"error": f"Claude returned {response.status_code}"})}\n\n'
401
+ return
402
+
403
+ async for line in response.aiter_lines():
404
+ if line.startswith('data: '):
405
+ try:
406
+ data = json.loads(line[6:])
407
+ if data.get('type') == 'content_block_delta' and data.get('delta', {}).get('text'):
408
+ text = data['delta']['text']
409
+ full_text += text
410
+
411
+ # ReAct: 检测工具调用
412
+ react_result = parse_react_output(full_text)
413
+ if react_result and react_result['type'] == 'tool_use':
414
+ chunk = {
415
+ "id": f"chatcmpl-{int(datetime.now().timestamp())}",
416
+ "object": "chat.completion.chunk",
417
+ "created": int(datetime.now().timestamp()),
418
+ "model": model,
419
+ "choices": [{
420
+ "index": 0,
421
+ "delta": {
422
+ "tool_calls": [{
423
+ "id": react_result['id'],
424
+ "type": "function",
425
+ "function": {
426
+ "name": react_result['name'],
427
+ "arguments": json.dumps(react_result['input'])
428
+ }
429
+ }]
430
+ },
431
+ "finish_reason": "tool_calls"
432
+ }]
433
+ }
434
+ yield f"data: {json.dumps(chunk)}\n\n"
435
+ yield "data: [DONE]\n\n"
436
+ return
437
+
438
+ # 正常文本
439
+ chunk = {
440
+ "id": f"chatcmpl-{int(datetime.now().timestamp())}",
441
+ "object": "chat.completion.chunk",
442
+ "created": int(datetime.now().timestamp()),
443
+ "model": model,
444
+ "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]
445
+ }
446
+ yield f"data: {json.dumps(chunk)}\n\n"
447
+ except:
448
+ pass
449
+ yield "data: [DONE]\n\n"
450
+ except Exception as e:
451
+ logger.error(f"Stream error: {e}")
452
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
453
+
454
+ return StreamingResponse(generate(), media_type="text/event-stream")
455
+
456
+ except Exception as e:
457
+ duration = (datetime.now() - start_time).total_seconds()
458
+ logger.error(f"Request failed: {e}, duration={duration}s")
459
+ return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
460
+
461
+ if __name__ == "__main__":
462
+ import uvicorn
463
+ uvicorn.run(app, host="0.0.0.0", port=7860)