heiyuheiyu commited on
Commit
2588173
·
verified ·
1 Parent(s): d110fe6

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +311 -0
  2. requirements.txt +5 -0
app.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import httpx
8
+ import json
9
+ import asyncio
10
+ from datetime import datetime
11
+ from typing import Optional, Dict
12
+ import os
13
+ import uuid
14
+ import re
15
+ import pickle
16
+ from pathlib import Path
17
+ import logging
18
+
19
+ # 日志配置
20
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # 重试函数
24
+ async def retry_async(fn, max_retries=3, delay=1.0):
25
+ for i in range(max_retries):
26
+ try:
27
+ return await fn()
28
+ except Exception as e:
29
+ if i == max_retries - 1:
30
+ raise
31
+ logger.warning(f"Retry {i+1}/{max_retries}: {e}")
32
+ await asyncio.sleep(delay * (i + 1))
33
+
34
+ app = FastAPI(title="Claude Proxy")
35
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
36
+
37
+ # 文件持久化
38
+ CACHE_FILE = Path("/tmp/conversation_cache.pkl")
39
+ conversation_cache: Dict[str, str] = {}
40
+
41
+ def load_cache():
42
+ global conversation_cache
43
+ if CACHE_FILE.exists():
44
+ try:
45
+ with open(CACHE_FILE, 'rb') as f:
46
+ conversation_cache = pickle.load(f)
47
+ except:
48
+ conversation_cache = {}
49
+
50
+ def save_cache():
51
+ try:
52
+ with open(CACHE_FILE, 'wb') as f:
53
+ pickle.dump(conversation_cache, f)
54
+ except:
55
+ pass
56
+
57
+ # 智能保活
58
+ last_request_time = datetime.now()
59
+
60
+ async def keep_alive():
61
+ while True:
62
+ # 根据最后请求时间动态调整保活间隔
63
+ idle_time = (datetime.now() - last_request_time).total_seconds()
64
+ if idle_time < 3600: # 1小时内有请求,频繁保活
65
+ await asyncio.sleep(1800) # 30分钟
66
+ else: # 空闲,降低频率
67
+ await asyncio.sleep(6 * 3600) # 6小时
68
+
69
+ try:
70
+ async with httpx.AsyncClient() as client:
71
+ url = os.getenv("SPACE_URL", "")
72
+ if url:
73
+ await client.get(f"{url}/health")
74
+ except:
75
+ pass
76
+
77
+ @app.on_event("startup")
78
+ async def startup():
79
+ load_cache()
80
+ asyncio.create_task(keep_alive())
81
+
82
+ @app.get("/health")
83
+ async def health():
84
+ return {"status": "ok", "time": datetime.now().isoformat()}
85
+
86
+ @app.get("/")
87
+ async def root():
88
+ return {"name": "Claude Proxy", "version": "2.0-hf-react"}
89
+
90
+ async def get_org_id(key: str) -> Optional[str]:
91
+ try:
92
+ async with httpx.AsyncClient() as client:
93
+ r = await client.get('https://claude.ai/api/organizations',
94
+ headers={'Cookie': f'sessionKey={key}', 'User-Agent': 'Mozilla/5.0'}, timeout=30.0)
95
+ if r.status_code == 200:
96
+ data = r.json()
97
+ return data[0]['uuid'] if data else None
98
+ except:
99
+ pass
100
+ return None
101
+
102
+ async def create_conv(key: str, org_id: str) -> Optional[str]:
103
+ try:
104
+ conv_id = str(uuid.uuid4())
105
+ async with httpx.AsyncClient() as client:
106
+ r = await client.post(f'https://claude.ai/api/organizations/{org_id}/chat_conversations',
107
+ headers={'Content-Type': 'application/json', 'Cookie': f'sessionKey={key}', 'User-Agent': 'Mozilla/5.0'},
108
+ json={'uuid': conv_id, 'name': '', 'include_conversation_preferences': True}, timeout=30.0)
109
+ if r.status_code == 200:
110
+ return r.json().get('uuid', conv_id)
111
+ except:
112
+ pass
113
+ return None
114
+
115
+ def extract_prompt(messages):
116
+ result = []
117
+ for m in messages:
118
+ role = m['role'].title()
119
+ content = m['content']
120
+
121
+ # 支持图片
122
+ if isinstance(content, list):
123
+ parts = []
124
+ for part in content:
125
+ if part.get('type') == 'text':
126
+ parts.append(part['text'])
127
+ elif part.get('type') == 'image_url':
128
+ parts.append(f"[Image: {part['image_url']['url']}]")
129
+ result.append(f"{role}: {' '.join(parts)}")
130
+ else:
131
+ result.append(f"{role}: {content}")
132
+
133
+ return '\n\n'.join(result)
134
+
135
+ def extract_attachments(messages):
136
+ attachments = []
137
+ for m in messages:
138
+ if isinstance(m['content'], list):
139
+ for part in m['content']:
140
+ if part.get('type') == 'image_url':
141
+ attachments.append({
142
+ 'extracted_content': '',
143
+ 'file_name': 'image.png',
144
+ 'file_size': 0,
145
+ 'file_type': 'image/png',
146
+ 'url': part['image_url']['url']
147
+ })
148
+ return attachments
149
+
150
+ def enhance_prompt(prompt: str, messages: list) -> str:
151
+ has_system = any(m['role'] == 'system' for m in messages)
152
+ if not has_system:
153
+ return prompt
154
+ return prompt + "\n\n[Environment: OpenClaw | Tools: web_search, artifacts, repl available]"
155
+
156
+ def inject_react_prompt(prompt: str, tools: list) -> str:
157
+ if not tools:
158
+ return prompt
159
+ tool_defs = '\n\n'.join([f"{t['name']}: {t.get('description', '')}" for t in tools])
160
+ return prompt + f"""
161
+
162
+ [Tool Calling Protocol]
163
+ You have access to these tools:
164
+ {tool_defs}
165
+
166
+ Use this format:
167
+ Thought: [reasoning]
168
+ Action: [tool_name]
169
+ Action Input: [JSON]
170
+
171
+ After result:
172
+ Observation: [result]
173
+
174
+ When done:
175
+ Final Answer: [response]
176
+ """
177
+
178
+ def parse_react_output(text: str) -> Optional[dict]:
179
+ action_match = re.search(r'Action:\s*(\w+)', text)
180
+ input_match = re.search(r'Action Input:\s*(\{[^}]*\})', text)
181
+ if action_match and input_match:
182
+ try:
183
+ return {'type': 'tool_use', 'id': f'tool_{int(datetime.now().timestamp())}',
184
+ 'name': action_match.group(1), 'input': json.loads(input_match.group(1))}
185
+ except:
186
+ return None
187
+ return None
188
+
189
+ @app.post("/v1/chat/completions")
190
+ async def chat(request: Request):
191
+ global last_request_time
192
+ last_request_time = datetime.now()
193
+ start_time = datetime.now()
194
+
195
+ try:
196
+ body = await request.json()
197
+ auth = request.headers.get('Authorization', '')
198
+
199
+ logger.info(f"Request received: model={body.get('model', 'claude-sonnet-4-6')}")
200
+
201
+ if not auth.startswith('Bearer '):
202
+ return JSONResponse({"error": {"message": "Missing auth", "type": "auth_error"}}, 401)
203
+
204
+ key = auth.replace('Bearer ', '')
205
+ org_id = await retry_async(lambda: get_org_id(key))
206
+ if not org_id:
207
+ logger.error("Invalid sessionKey")
208
+ return JSONResponse({"error": {"message": "Invalid key", "type": "auth_error"}}, 401)
209
+
210
+ conv_id = conversation_cache.get(key)
211
+ if not conv_id:
212
+ conv_id = await retry_async(lambda: create_conv(key, org_id))
213
+ if conv_id:
214
+ conversation_cache[key] = conv_id
215
+ save_cache()
216
+ if not conv_id:
217
+ logger.error("Failed to create conversation")
218
+ return JSONResponse({"error": {"message": "Conv error", "type": "api_error"}}, 500)
219
+
220
+ messages = body.get('messages', [])
221
+ tools = body.get('tools', [])
222
+ prompt = extract_prompt(messages)
223
+ prompt = enhance_prompt(prompt, messages)
224
+ if tools:
225
+ prompt = inject_react_prompt(prompt, tools)
226
+
227
+ attachments = extract_attachments(messages)
228
+ model = body.get('model', 'claude-sonnet-4-6')
229
+ payload = {
230
+ 'prompt': prompt,
231
+ 'timezone': body.get('timezone', 'Asia/Shanghai'),
232
+ 'locale': body.get('locale', 'zh-CN'),
233
+ 'model': model,
234
+ 'rendering_mode': 'messages',
235
+ 'attachments': attachments,
236
+ 'files': [],
237
+ 'tools': [
238
+ {"type": "web_search_v0", "name": "web_search"},
239
+ {"type": "artifacts_v0", "name": "artifacts"},
240
+ {"type": "repl_v0", "name": "repl"}
241
+ ]
242
+ }
243
+
244
+ logger.info(f"Sending to Claude: attachments={len(attachments)}")
245
+
246
+ async def generate():
247
+ try:
248
+ full_text = ''
249
+ async with httpx.AsyncClient() as client:
250
+ async with client.stream('POST',
251
+ f'https://claude.ai/api/organizations/{org_id}/chat_conversations/{conv_id}/completion',
252
+ headers={'Content-Type': 'application/json', 'Cookie': f'sessionKey={key}', 'User-Agent': 'Mozilla/5.0'},
253
+ json=payload, timeout=120.0) as response:
254
+
255
+ async for line in response.aiter_lines():
256
+ if line.startswith('data: '):
257
+ try:
258
+ data = json.loads(line[6:])
259
+ if data.get('type') == 'content_block_delta' and data.get('delta', {}).get('text'):
260
+ text = data['delta']['text']
261
+ full_text += text
262
+
263
+ # ReAct: 检测工具调用
264
+ react_result = parse_react_output(full_text)
265
+ if react_result and react_result['type'] == 'tool_use':
266
+ chunk = {
267
+ "id": f"chatcmpl-{int(datetime.now().timestamp())}",
268
+ "object": "chat.completion.chunk",
269
+ "created": int(datetime.now().timestamp()),
270
+ "model": model,
271
+ "choices": [{
272
+ "index": 0,
273
+ "delta": {
274
+ "tool_calls": [{
275
+ "id": react_result['id'],
276
+ "type": "function",
277
+ "function": {
278
+ "name": react_result['name'],
279
+ "arguments": json.dumps(react_result['input'])
280
+ }
281
+ }]
282
+ },
283
+ "finish_reason": "tool_calls"
284
+ }]
285
+ }
286
+ yield f"data: {json.dumps(chunk)}\n\n"
287
+ yield "data: [DONE]\n\n"
288
+ return
289
+
290
+ # 正常文本
291
+ chunk = {
292
+ "id": f"chatcmpl-{int(datetime.now().timestamp())}",
293
+ "object": "chat.completion.chunk",
294
+ "created": int(datetime.now().timestamp()),
295
+ "model": model,
296
+ "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]
297
+ }
298
+ yield f"data: {json.dumps(chunk)}\n\n"
299
+ except:
300
+ pass
301
+ yield "data: [DONE]\n\n"
302
+ except Exception as e:
303
+ logger.error(f"Stream error: {e}")
304
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
305
+
306
+ return StreamingResponse(generate(), media_type="text/event-stream")
307
+
308
+ except Exception as e:
309
+ duration = (datetime.now() - start_time).total_seconds()
310
+ logger.error(f"Request failed: {e}, duration={duration}s")
311
+ return JSONResponse({"error": {"message": str(e), "type": "api_error"}}, 500)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.115.2
2
+ uvicorn>=0.24.0
3
+ httpx>=0.25.1
4
+ sse-starlette>=1.8.2
5
+ python-multipart>=0.0.6