heiyuheiyu commited on
Commit
37839db
·
verified ·
1 Parent(s): 17dc3b5

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -549
app.py DELETED
@@ -1,549 +0,0 @@
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
- def load_cache():
40
- global conversation_cache
41
- if CACHE_FILE.exists():
42
- try:
43
- with open(CACHE_FILE, 'rb') as f:
44
- conversation_cache = pickle.load(f)
45
- except:
46
- conversation_cache = {}
47
-
48
- def save_cache():
49
- try:
50
- with open(CACHE_FILE, 'wb') as f:
51
- pickle.dump(conversation_cache, f)
52
- except:
53
- pass
54
-
55
- # 智能保活
56
- last_request_time = datetime.now()
57
-
58
- async def keep_alive():
59
- while True:
60
- idle_time = (datetime.now() - last_request_time).total_seconds()
61
- if idle_time < 3600:
62
- await asyncio.sleep(1800)
63
- else:
64
- await asyncio.sleep(6 * 3600)
65
- try:
66
- async with httpx.AsyncClient() as client:
67
- url = os.getenv("SPACE_URL", "")
68
- if url:
69
- await client.get(f"{url}/health")
70
- except:
71
- pass
72
-
73
- @asynccontextmanager
74
- async def lifespan(app: FastAPI):
75
- load_cache()
76
- logger.info("Application started")
77
- yield
78
- logger.info("Application shutdown")
79
-
80
- app = FastAPI(title="Claude Proxy", lifespan=lifespan)
81
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
82
-
83
- # 全局请求日志中间件
84
- @app.middleware("http")
85
- async def log_requests(request: Request, call_next):
86
- logger.info(f"[GLOBAL] {request.method} {request.url.path} from {request.client.host if request.client else 'unknown'}")
87
- response = await call_next(request)
88
- return response
89
-
90
- @app.get("/clear_cache")
91
- async def clear_cache():
92
- global conversation_cache
93
- conversation_cache = {}
94
- if CACHE_FILE.exists():
95
- CACHE_FILE.unlink()
96
- return {"status": "cache cleared"}
97
-
98
- @app.get("/logs")
99
- async def get_logs():
100
- """获取最近的日志"""
101
- try:
102
- # 读取日志(假设日志输出到标准输出)
103
- import subprocess
104
- result = subprocess.run(['tail', '-n', '100', '/proc/1/fd/1'],
105
- capture_output=True, text=True, timeout=5)
106
- return {"logs": result.stdout}
107
- except:
108
- return {"error": "Cannot read logs"}
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
- def anthropic_to_openai_messages(messages: list) -> list:
246
- """Convert Anthropic format messages to OpenAI format"""
247
- result = []
248
- for m in messages:
249
- role = m['role']
250
- content = m['content']
251
-
252
- if isinstance(content, list):
253
- # Handle multi-part content
254
- parts = []
255
- for part in content:
256
- if part.get('type') == 'text':
257
- parts.append({'type': 'text', 'text': part['text']})
258
- elif part.get('type') == 'image':
259
- parts.append({'type': 'image_url', 'image_url': {'url': part['source']['data']}})
260
- result.append({'role': role, 'content': parts})
261
- else:
262
- result.append({'role': role, 'content': content})
263
-
264
- return result
265
-
266
- @app.post("/v1/messages")
267
- async def anthropic_messages(request: Request):
268
- """Anthropic API format endpoint"""
269
- global last_request_time
270
- last_request_time = datetime.now()
271
-
272
- try:
273
- body = await request.json()
274
- auth = request.headers.get('x-api-key') or request.headers.get('Authorization', '').replace('Bearer ', '')
275
-
276
- if not auth:
277
- return JSONResponse({"error": {"message": "Missing API key", "type": "authentication_error"}}, 401)
278
-
279
- # Convert to OpenAI format
280
- messages = anthropic_to_openai_messages(body.get('messages', []))
281
- if body.get('system'):
282
- messages.insert(0, {'role': 'system', 'content': body['system']})
283
-
284
- # Process using shared logic
285
- return await process_chat_request(auth, messages, body.get('model', 'claude-sonnet-4-6'),
286
- body.get('stream', False), is_anthropic=True)
287
-
288
- except Exception as e:
289
- logger.error(f"Anthropic endpoint error: {e}")
290
- return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
291
-
292
- @app.post("/v1/chat/completions")
293
- @app.post("/chat/completions")
294
- async def chat_openai(request: Request):
295
- """OpenAI API format endpoint"""
296
- global last_request_time
297
- last_request_time = datetime.now()
298
-
299
- try:
300
- body = await request.json()
301
- auth = request.headers.get('Authorization', '')
302
-
303
- if not auth.startswith('Bearer '):
304
- return JSONResponse({"error": {"message": "Missing auth", "type": "auth_error"}}, 401)
305
-
306
- key = auth.replace('Bearer ', '')
307
- return await process_chat_request(key, body.get('messages', []), body.get('model', 'claude-sonnet-4-6'),
308
- body.get('stream', True), body.get('tools', []), is_anthropic=False)
309
-
310
- except Exception as e:
311
- logger.error(f"OpenAI endpoint error: {e}")
312
- return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
313
-
314
- @app.post("/v1/claude-code/messages")
315
- async def claude_code_messages(request: Request):
316
- """Claude Code API format endpoint"""
317
- global last_request_time
318
- last_request_time = datetime.now()
319
-
320
- try:
321
- body = await request.json()
322
- auth = request.headers.get('x-api-key') or request.headers.get('Authorization', '').replace('Bearer ', '')
323
-
324
- if not auth:
325
- return JSONResponse({"error": {"message": "Missing API key", "type": "authentication_error"}}, 401)
326
-
327
- messages = anthropic_to_openai_messages(body.get('messages', []))
328
- if body.get('system'):
329
- messages.insert(0, {'role': 'system', 'content': body['system']})
330
-
331
- return await process_chat_request(auth, messages, body.get('model', 'claude-sonnet-4-6'),
332
- body.get('stream', False), is_anthropic=True)
333
-
334
- except Exception as e:
335
- logger.error(f"Claude Code endpoint error: {e}")
336
- return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
337
-
338
- async def process_chat_request(key: str, messages: list, model: str, stream: bool, tools: list = None, is_anthropic: bool = False):
339
- start_time = datetime.now()
340
-
341
- try:
342
- logger.info("="*50)
343
- logger.info(f"[1] Request received (format: {'Anthropic' if is_anthropic else 'OpenAI'})")
344
- logger.info(f" Model: {model}, Stream: {stream}")
345
- logger.info("="*50)
346
-
347
- logger.info(f"[3] Calling get_org_id with sessionKey={key[:20]}...")
348
- org_id = await retry_async(lambda: get_org_id(key))
349
- if not org_id:
350
- logger.error(f"[2] Invalid key")
351
- return JSONResponse({"error": {"message": "Invalid key or network error", "type": "auth_error"}}, 401)
352
-
353
- conv_id = conversation_cache.get(key)
354
- if not conv_id:
355
- conv_id = await retry_async(lambda: create_conv(key, org_id))
356
- if conv_id:
357
- conversation_cache[key] = conv_id
358
- save_cache()
359
- if not conv_id:
360
- logger.error("Failed to create conversation")
361
- return JSONResponse({"error": {"message": "Conv error", "type": "api_error"}}, 500)
362
-
363
- tools = tools or []
364
- prompt = extract_prompt(messages)
365
- prompt = enhance_prompt(prompt, messages)
366
- if tools:
367
- prompt = inject_react_prompt(prompt, tools)
368
-
369
- attachments = extract_attachments(messages)
370
- payload = {
371
- 'prompt': prompt,
372
- 'timezone': 'Asia/Shanghai',
373
- 'locale': 'en-US',
374
- 'model': model,
375
- 'rendering_mode': 'messages',
376
- 'attachments': attachments,
377
- 'files': [],
378
- 'tools': [
379
- {"type": "web_search_v0", "name": "web_search"},
380
- {"type": "artifacts_v0", "name": "artifacts"},
381
- {"type": "repl_v0", "name": "repl"}
382
- ]
383
- }
384
-
385
- logger.info(f"Sending to Claude: attachments={len(attachments)}")
386
-
387
- # 非流式响应
388
- if not stream:
389
- return await handle_non_stream(key, org_id, conv_id, payload, model, is_anthropic)
390
-
391
- # 流式响应
392
- return await handle_stream(key, org_id, conv_id, payload, model, tools, is_anthropic)
393
-
394
- except Exception as e:
395
- duration = (datetime.now() - start_time).total_seconds()
396
- logger.error(f"Request failed: {e}, duration={duration}s")
397
- return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
398
-
399
- async def handle_non_stream(key, org_id, conv_id, payload, model, is_anthropic):
400
- try:
401
- full_text = ''
402
- headers = {
403
- 'Content-Type': 'application/json',
404
- 'Cookie': f'sessionKey={key}',
405
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
406
- 'Accept': 'text/event-stream',
407
- 'Referer': 'https://claude.ai/chats',
408
- 'Origin': 'https://claude.ai'
409
- }
410
- async with httpx.AsyncClient() as client:
411
- async with client.stream('POST',
412
- f'https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion',
413
- headers=headers, json=payload, timeout=120.0) as response:
414
-
415
- async for line in response.aiter_lines():
416
- if line.startswith('data: '):
417
- try:
418
- data = json.loads(line[6:])
419
- if data.get('type') == 'content_block_delta' and data.get('delta', ).get('text'):
420
- full_text += data['delta']['text']
421
- except:
422
- pass
423
-
424
- if is_anthropic:
425
- return JSONResponse({
426
- "id": f"msg_{int(datetime.now().timestamp())}",
427
- "type": "message",
428
- "role": "assistant",
429
- "content": [{"type": "text", "text": full_text}],
430
- "model": model,
431
- "stop_reason": "end_turn"
432
- })
433
- else:
434
- return JSONResponse({
435
- "id": f"chatcmpl-{int(datetime.now().timestamp())}",
436
- "object": "chat.completion",
437
- "created": int(datetime.now().timestamp()),
438
- "model": model,
439
- "choices": [{
440
- "index": 0,
441
- "message": {"role": "assistant", "content": full_text},
442
- "finish_reason": "stop"
443
- }]
444
- })
445
- except Exception as e:
446
- logger.error(f"Non-stream error: {e}")
447
- return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
448
-
449
- async def handle_stream(key, org_id, conv_id, payload, model, tools, is_anthropic):
450
- async def generate():
451
- try:
452
- full_text = ''
453
- headers = {
454
- 'Content-Type': 'application/json',
455
- 'Cookie': f'sessionKey={key}',
456
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
457
- 'Accept': 'text/event-stream',
458
- 'Referer': 'https://claude.ai/chats',
459
- 'Origin': 'https://claude.ai'
460
- }
461
- async with httpx.AsyncClient() as client:
462
- async with client.stream('POST',
463
- f'https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion',
464
- headers=headers, json=payload, timeout=120.0) as response:
465
-
466
- if response.status_code != 200:
467
- error_body = await response.aread()
468
- logger.error(f"Claude API error: {response.status_code}")
469
- yield f'data: {json.dumps({"error": f"Claude returned {response.status_code}"})}\n\n'
470
- return
471
-
472
- async for line in response.aiter_lines():
473
- if line.startswith('data: '):
474
- try:
475
- data = json.loads(line[6:])
476
- if data.get('type') == 'content_block_delta' and data.get('delta', {}).get('text'):
477
- text = data['delta']['text']
478
- full_text += text
479
-
480
- # ReAct tool detection
481
- if tools:
482
- react_result = parse_react_output(full_text)
483
- if react_result and react_result['type'] == 'tool_use':
484
- if is_anthropic:
485
- chunk = {
486
- "type": "content_block_start",
487
- "index": 0,
488
- "content_block": {
489
- "type": "tool_use",
490
- "id": react_result['id'],
491
- "name": react_result['name'],
492
- "input": react_result['input']
493
- }
494
- }
495
- else:
496
- chunk = {
497
- "id": f"chatcmpl-{int(datetime.now().timestamp())}",
498
- "object": "chat.completion.chunk",
499
- "created": int(datetime.now().timestamp()),
500
- "model": model,
501
- "choices": [{
502
- "index": 0,
503
- "delta": {
504
- "tool_calls": [{
505
- "id": react_result['id'],
506
- "type": "function",
507
- "function": {
508
- "name": react_result['name'],
509
- "arguments": json.dumps(react_result['input'])
510
- }
511
- }]
512
- },
513
- "finish_reason": "tool_calls"
514
- }]
515
- }
516
- yield f"data: {json.dumps(chunk)}\n\n"
517
- yield "data: [DONE]\n\n" if not is_anthropic else ""
518
- return
519
-
520
- # Normal text
521
- if is_anthropic:
522
- chunk = {
523
- "type": "content_block_delta",
524
- "index": 0,
525
- "delta": {"type": "text_delta", "text": text}
526
- }
527
- else:
528
- chunk = {
529
- "id": f"chatcmpl-{int(datetime.now().timestamp())}",
530
- "object": "chat.completion.chunk",
531
- "created": int(datetime.now().timestamp()),
532
- "model": model,
533
- "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]
534
- }
535
- yield f"data: {json.dumps(chunk)}\n\n"
536
- except:
537
- pass
538
-
539
- if not is_anthropic:
540
- yield "data: [DONE]\n\n"
541
- except Exception as e:
542
- logger.error(f"Stream error: {e}")
543
- yield f"data: {json.dumps({'error': str(e)})}\n\n"
544
-
545
- return StreamingResponse(generate(), media_type="text/event-stream")
546
-
547
- if __name__ == "__main__":
548
- import uvicorn
549
- uvicorn.run(app, host="0.0.0.0", port=7860)