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

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +549 -0
app.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)