nettw/SambaNova2API
0
1"""2SambaNova OpenAI 接口代理 (支持模型列表透传和自动登录)3"""4 5import os6import uuid7import json8import time9import asyncio10import httpx11import secrets12import urllib.parse13from typing import Optional, Dict, Any14from fastapi import FastAPI, Request, HTTPException, Depends, Header15from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse16from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials17from fake_useragent import UserAgent18# 修复 Pydantic 导入19try:20 # 尝试从 pydantic-settings 导入 (Pydantic v2)21 from pydantic_settings import BaseSettings22except ImportError:23 # 回退到旧版本 (Pydantic v1)24 from pydantic import BaseSettings25 26# ================ 配置 ================27class Settings(BaseSettings):28 # SambaNova 配置29 SAMBA_EMAIL: str = os.getenv("SAMBA_EMAIL", "")30 SAMBA_PASSWORD: str = os.getenv("SAMBA_PASSWORD", "")31 SAMBA_COMPLETION_URL: str = os.getenv("SAMBA_COMPLETION_URL", "https://cloud.sambanova.ai/api/completion")32 SAMBA_MODELS_URL: str = os.getenv("SAMBA_MODELS_URL", "https://api.sambanova.ai/v1/models")33 34 # 本地API密钥配置35 LOCAL_API_KEY: str = os.getenv("LOCAL_API_KEY", secrets.token_urlsafe(32))36 37 # 其他配置38 TOKEN_CACHE_TIME: int = int(os.getenv("TOKEN_CACHE_TIME", 604800)) # 默认缓存7天 (7*24*60*60=604800秒)39 FINGERPRINT_PREFIX: str = os.getenv("FINGERPRINT_PREFIX", "anon_")40 41 # 添加缺少的字段42 domain: str = os.getenv("domain", "")43 temp_mail: str = os.getenv("temp_mail", "")44 temp_mail_ext: str = os.getenv("temp_mail_ext", "")45 rate_limit_wait: int = int(os.getenv("rate_limit_wait", 3600))46 47 class Config:48 env_file = ".env"49 extra = "ignore" # 忽略额外的字段,避免类似错误50 51settings = Settings()52# =====================================53 54app = FastAPI(title="SambaNova OpenAI Proxy with Auto-Login")55security = HTTPBearer()56 57# 全局变量存储访问令牌和过期时间58access_token = None59token_expiry = 060token_lock = asyncio.Lock()61 62def generate_fingerprint() -> str:63 """生成符合格式要求的随机指纹"""64 return f"{settings.FINGERPRINT_PREFIX}{uuid.uuid4().hex[:20]}"65 66async def validate_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:67 """验证本地API密钥并返回SambaNova访问令牌"""68 api_key = credentials.credentials69 70 # 如果未配置本地API密钥或为空,则跳过验证71 if settings.LOCAL_API_KEY and settings.LOCAL_API_KEY.strip():72 # 验证本地API密钥73 if api_key != settings.LOCAL_API_KEY:74 raise HTTPException(75 status_code=401,76 detail="Invalid API key",77 headers={"WWW-Authenticate": "Bearer"},78 )79 else:80 print("[警告] LOCAL_API_KEY未配置或为空,跳过API密钥验证")81 82 # 获取或刷新SambaNova访问令牌83 token = await get_samba_token()84 if not token:85 raise HTTPException(86 status_code=500,87 detail="Failed to obtain SambaNova access token. Check server logs for details."88 )89 90 return token91 92async def get_samba_token() -> Optional[str]:93 """获取或刷新SambaNova访问令牌"""94 global access_token, token_expiry95 96 # 使用锁防止并发请求同时刷新令牌97 async with token_lock:98 current_time = time.time()99 100 # 如果令牌有效,直接返回101 if access_token and current_time < token_expiry:102 print(f"[令牌] 使用缓存令牌: {access_token}")103 return access_token104 105 # 否则获取新令牌106 try:107 # 检查凭据是否已配置108 if not settings.SAMBA_EMAIL or not settings.SAMBA_PASSWORD:109 print("[错误] 未配置SambaNova凭据,请设置SAMBA_EMAIL和SAMBA_PASSWORD环境变量")110 return None111 112 print(f"[令牌] 开始获取新令牌... 邮箱: {settings.SAMBA_EMAIL}")113 auth = SambaAuthAsync(settings.SAMBA_EMAIL, settings.SAMBA_PASSWORD)114 new_token = await auth.login()115 116 if new_token:117 access_token = new_token118 token_expiry = current_time + settings.TOKEN_CACHE_TIME119 print(f"[令牌更新成功] 完整令牌: {new_token}")120 print(f"[令牌更新成功] 令牌将在 {settings.TOKEN_CACHE_TIME} 秒后过期")121 return access_token122 else:123 print("[令牌获取失败] 请检查SambaNova凭据是否正确")124 return None125 except Exception as e:126 print(f"[令牌获取异常] {str(e)}")127 return None128 129def reset_token_expiry():130 """重置令牌过期时间,强制下次请求重新获取令牌"""131 global token_expiry132 token_expiry = 0133 print("[令牌] 令牌已过期,将在下次请求时重新获取")134 135async def forward_get_request(url: str, token: str) -> httpx.Response:136 """转发 GET 请求到目标接口"""137 headers = {138 "accept": "application/json",139 "user-agent": "SambaNova-Proxy/1.0",140 "origin": "https://cloud.sambanova.ai",141 "referer": "https://cloud.sambanova.ai/"142 }143 144 cookies = {145 "access_token": token146 }147 148 async with httpx.AsyncClient() as client:149 try:150 resp = await client.get(151 url,152 headers=headers,153 cookies=cookies,154 timeout=10.0155 )156 157 # 检查是否需要刷新令牌158 if resp.status_code == 401:159 # 令牌已过期,需要刷新160 reset_token_expiry()161 raise HTTPException(401, "Token expired, please retry")162 163 resp.raise_for_status()164 return resp165 except httpx.HTTPStatusError as e:166 if e.response.status_code == 401:167 # 令牌已过期,需要刷新168 reset_token_expiry()169 raise HTTPException(401, "Token expired, please retry")170 raise HTTPException(e.response.status_code, f"Upstream error: {e.response.text}")171 172async def forward_post_request(url: str, payload: dict, token: str) -> httpx.Response:173 """转发 POST 请求到目标接口"""174 headers = {175 "content-type": "application/json",176 "user-agent": "SambaNova-Proxy/1.0",177 "origin": "https://cloud.sambanova.ai",178 "referer": "https://cloud.sambanova.ai/"179 }180 181 cookies = {182 "access_token": token183 }184 185 async with httpx.AsyncClient() as client:186 try:187 resp = await client.post(188 url,189 json=payload,190 headers=headers,191 cookies=cookies,192 timeout=30.0193 )194 195 # 检查是否需要刷新令牌196 if resp.status_code == 401:197 # 令牌已过期,需要刷新198 reset_token_expiry()199 raise HTTPException(401, "Token expired, please retry")200 201 resp.raise_for_status()202 return resp203 except httpx.HTTPStatusError as e:204 if e.response.status_code == 401:205 # 令牌已过期,需要刷新206 reset_token_expiry()207 raise HTTPException(401, "Token expired, please retry")208 raise HTTPException(e.response.status_code, f"Upstream error: {e.response.text}")209 210@app.get("/v1/models")211async def list_models(token: str = Depends(validate_api_key)):212 """透传模型列表接口"""213 try:214 resp = await forward_get_request(settings.SAMBA_MODELS_URL, token)215 content = resp.json()216 json_str = json.dumps(content, separators=(',', ':'), ensure_ascii=False)217 json_bytes = json_str.encode('utf-8')218 return JSONResponse(219 content=content,220 headers={221 "Content-Type": "application/json",222 "Content-Length": str(len(json_bytes)),223 "Cache-Control": "public, max-age=300"224 }225 )226 except httpx.RequestError as e:227 raise HTTPException(504, f"Gateway timeout: {str(e)}")228 except Exception as e:229 raise HTTPException(500, f"Internal server error: {str(e)}")230 231@app.post("/v1/chat/completions")232async def chat_completions(233 request: Request,234 token: str = Depends(validate_api_key)235):236 """处理对话请求"""237 try:238 openai_payload = await request.json()239 print(f"[请求] 收到聊天请求,模型: {openai_payload.get('model', 'DeepSeek-R1')}")240 241 samba_payload = {242 "body": {243 "model": openai_payload.get("model", "DeepSeek-R1"),244 "messages": openai_payload["messages"],245 "stream": True,246 "stop": openai_payload.get("stop", ["<|eot_id|>"]),247 "temperature": openai_payload.get("temperature", 0),248 "max_tokens": openai_payload.get("max_tokens", 2048),249 "do_sample": openai_payload.get("temperature", 0) > 0250 },251 "env_type": "text",252 "fingerprint": generate_fingerprint()253 }254 255 print(f"[转发] 使用令牌 {token[:10]}... 转发请求到 SambaNova")256 resp = await forward_post_request(settings.SAMBA_COMPLETION_URL, samba_payload, token)257 print(f"[响应] 成功获取响应,开始流式传输")258 259 return StreamingResponse(260 resp.aiter_bytes(),261 media_type="text/event-stream",262 headers={263 "X-Proxy-Version": "1.0",264 "X-Request-ID": str(uuid.uuid4())265 }266 )267 except HTTPException as e:268 print(f"[错误] HTTP异常: {e.detail}")269 raise270 except httpx.RequestError as e:271 print(f"[错误] 请求错误: {str(e)}")272 raise HTTPException(504, f"Gateway timeout: {str(e)}")273 except Exception as e:274 print(f"[错误] 未处理异常: {str(e)}")275 raise HTTPException(500, f"Internal server error: {str(e)}")276 277@app.get("/info")278async def get_info():279 """获取服务信息"""280 return {281 "status": "running",282 "api_key_configured": bool(settings.LOCAL_API_KEY),283 "samba_credentials_configured": bool(settings.SAMBA_EMAIL and settings.SAMBA_PASSWORD),284 "token_status": "active" if access_token and time.time() < token_expiry else "not_available",285 "token_expires_in": max(0, int(token_expiry - time.time())) if access_token else 0286 }287 288@app.get("/debug/token", include_in_schema=False)289async def debug_token():290 """调试端点:检查当前令牌状态"""291 global access_token, token_expiry292 current_time = time.time()293 294 return {295 "token_exists": access_token is not None,296 "token_prefix": access_token[:10] + "..." if access_token else None,297 "token_valid": access_token is not None and current_time < token_expiry,298 "expires_in_seconds": max(0, int(token_expiry - current_time)) if access_token else 0,299 "current_time": current_time,300 "expiry_time": token_expiry,301 }302 303@app.get("/", response_class=HTMLResponse)304async def root():305 """根路由健康检查,返回HTML界面"""306 current_time = time.time()307 token_valid = access_token is not None and current_time < token_expiry308 expires_in = max(0, int(token_expiry - current_time)) if access_token else 0309 310 # 计算过期时间的可读格式311 if expires_in > 0:312 days = expires_in // 86400313 hours = (expires_in % 86400) // 3600314 minutes = (expires_in % 3600) // 60315 expiry_readable = f"{days}天 {hours}小时 {minutes}分钟"316 else:317 expiry_readable = "已过期"318 319 # 使用东八区时间(中国标准时间)320 import datetime321 from datetime import timezone, timedelta322 323 # 创建东八区时区对象324 china_tz = timezone(timedelta(hours=8))325 # 获取当前UTC时间并转换为东八区时间326 current_time_china = datetime.datetime.now(china_tz)327 formatted_time = current_time_china.strftime('%Y-%m-%d %H:%M:%S')328 329 html_content = f"""330 <!DOCTYPE html>331 <html>332 <head>333 <title>SambaNova OpenAI 代理服务</title>334 <meta charset="UTF-8">335 <meta name="viewport" content="width=device-width, initial-scale=1.0">336 <style>337 body {{338 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;339 line-height: 1.6;340 color: #333;341 max-width: 800px;342 margin: 0 auto;343 padding: 20px;344 }}345 h1 {{346 color: #2c3e50;347 border-bottom: 1px solid #eee;348 padding-bottom: 10px;349 }}350 .status-card {{351 background-color: #f8f9fa;352 border-radius: 8px;353 padding: 20px;354 margin-bottom: 20px;355 box-shadow: 0 2px 4px rgba(0,0,0,0.1);356 }}357 .status-item {{358 margin-bottom: 10px;359 display: flex;360 justify-content: space-between;361 }}362 .status-label {{363 font-weight: bold;364 color: #555;365 }}366 .status-value {{367 text-align: right;368 }}369 .status-healthy {{370 color: #28a745;371 font-weight: bold;372 }}373 .status-warning {{374 color: #ffc107;375 font-weight: bold;376 }}377 .status-error {{378 color: #dc3545;379 font-weight: bold;380 }}381 .code-block {{382 background-color: #f1f1f1;383 padding: 15px;384 border-radius: 5px;385 font-family: monospace;386 overflow-x: auto;387 }}388 .footer {{389 margin-top: 30px;390 font-size: 0.9em;391 color: #6c757d;392 text-align: center;393 }}394 </style>395 </head>396 <body>397 <h1>SambaNova OpenAI 代理服务</h1>398 399 <div class="status-card">400 <h2>服务状态</h2>401 <div class="status-item">402 <span class="status-label">状态:</span>403 <span class="status-value status-healthy">运行中</span>404 </div>405 <div class="status-item">406 <span class="status-label">版本:</span>407 <span class="status-value">1.0.0</span>408 </div>409 <div class="status-item">410 <span class="status-label">令牌状态:</span>411 <span class="status-value {('status-healthy' if token_valid else 'status-error')}">412 {('有效' if token_valid else '无效')}413 </span>414 </div>415 <div class="status-item">416 <span class="status-label">令牌过期时间:</span>417 <span class="status-value">{expiry_readable}</span>418 </div>419 <div class="status-item">420 <span class="status-label">SambaNova 凭据:</span>421 <span class="status-value {('status-healthy' if settings.SAMBA_EMAIL and settings.SAMBA_PASSWORD else 'status-error')}">422 {('已配置' if settings.SAMBA_EMAIL and settings.SAMBA_PASSWORD else '未配置')}423 </span>424 </div>425 <div class="status-item">426 <span class="status-label">本地API密钥:</span>427 <span class="status-value {('status-healthy' if settings.LOCAL_API_KEY else 'status-warning')}">428 {('已配置' if settings.LOCAL_API_KEY else '未配置')}429 </span>430 </div>431 </div>432 433 <div class="footer">434 <p>当前时间: {formatted_time} (中国标准时间)</p>435 </div>436 </body>437 </html>438 """439 440 return html_content441 442class SambaAuthAsync:443 def __init__(self, email, password):444 self.email = email445 self.password = password446 self.client = httpx.AsyncClient()447 self.ua = UserAgent()448 self.base_headers = {449 "accept": "*/*",450 "accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",451 "origin": "https://cloud.sambanova.ai",452 "referer": "https://cloud.sambanova.ai/",453 "user-agent": self.ua.random454 }455 self.config = None456 self.nonce = None # 确保nonce属性存在457 458 async def _get_config(self):459 """获取动态配置信息"""460 config_url = "https://cloud.sambanova.ai/api/config"461 response = await self.client.get(config_url, headers=self.base_headers)462 response.raise_for_status()463 self.config = response.json()464 print(f"[配置获取成功] ClientID: {self.config['clientId']}")465 466 async def _get_login_ticket(self):467 """获取登录票据"""468 auth_url = f"https://{self.config['issuerBaseUrl']}/co/authenticate"469 payload = {470 "client_id": self.config["clientId"],471 "username": self.email,472 "password": self.password,473 "realm": "Username-Password-Authentication",474 "credential_type": "http://auth0.com/oauth/grant-type/password-realm"475 }476 477 headers = {**self.base_headers, "content-type": "application/json"}478 479 response = await self.client.post(auth_url, headers=headers, json=payload)480 response.raise_for_status()481 return response.json()["login_ticket"]482 483 async def _get_auth_code(self, login_ticket: str):484 """获取授权码"""485 state = secrets.token_urlsafe(32)486 self.nonce = secrets.token_urlsafe(32) # 保存nonce到实例变量487 488 params = {489 "client_id": self.config["clientId"],490 "response_type": "code",491 "redirect_uri": self.config["redirectURL"],492 "scope": "openid profile email",493 "nonce": self.nonce,494 "state": state,495 "login_ticket": login_ticket,496 "realm": "Username-Password-Authentication",497 "auth0Client": "eyJuYW1lIjoibG9jay5qcyIsInZlcnNpb24iOiIxMi4zLjAiLCJlbnYiOnsiYXV0aDAuanMiOiI5LjIyLjEiLCJhdXRoMC5qcy11bHAiOiI5LjIyLjEifX0="498 }499 500 auth_url = f"https://{self.config['issuerBaseUrl']}/authorize"501 response = await self.client.get(502 auth_url,503 params=params,504 follow_redirects=False505 )506 507 if response.status_code == 302:508 location = response.headers["location"]509 parsed = urllib.parse.urlparse(location)510 query = urllib.parse.parse_qs(parsed.query)511 return query.get("code", [None])[0], state512 raise Exception(f"未收到302重定向,实际状态码:{response.status_code}")513 514 async def _exchange_token(self, code: str, state: str):515 """交换访问令牌"""516 # 设置必要的cookies517 self.client.cookies.set("nonce", self.nonce, domain="cloud.sambanova.ai")518 519 callback_url = f"{self.config['redirectURL']}?code={code}&state={state}"520 response = await self.client.get(521 callback_url,522 headers={523 **self.base_headers,524 "sec-fetch-site": "same-site",525 "sec-fetch-mode": "navigate",526 "sec-fetch-user": "?1",527 "sec-fetch-dest": "document"528 },529 follow_redirects=True530 )531 532 # 从cookies中提取access_token533 for cookie in self.client.cookies.jar:534 if cookie.name == "access_token" and "sambanova.ai" in cookie.domain:535 return cookie.value536 raise Exception("未找到access_token")537 538 async def login(self):539 """完整登录流程"""540 try:541 await self._get_config()542 login_ticket = await self._get_login_ticket()543 print(f"[登录票据获取成功] 完整票据: {login_ticket}")544 545 auth_code, state = await self._get_auth_code(login_ticket)546 if not auth_code:547 raise Exception("授权码获取失败")548 print(f"[授权码获取成功] 完整授权码: {auth_code}")549 print(f"[授权状态] state: {state}")550 551 token = await self._exchange_token(auth_code, state)552 print(f"[令牌获取成功] 完整令牌: {token}")553 return token554 555 except Exception as e:556 print(f"[登录失败] 详细错误: {str(e)}")557 return None558 finally:559 await self.client.aclose()560 561# 定义后台任务刷新令牌562async def token_refresh_task():563 global token_expiry, access_token564 565 while True:566 # 如果没有令牌或过期时间未设置,先获取一次567 if not access_token or token_expiry == 0:568 await get_samba_token()569 if not access_token:570 # 如果获取失败,等待一段时间后重试571 await asyncio.sleep(60)572 continue573 574 # 计算距离过期还有多少时间(秒)575 remaining_time = token_expiry - time.time()576 577 if remaining_time <= 0:578 # 如果已过期,立即刷新579 print("[后台任务] 令牌已过期,立即刷新")580 await get_samba_token()581 else:582 # 设置为过期前3分钟刷新583 refresh_before = min(remaining_time - 180, 3600) # 提前3分钟,但最长等待1小时584 refresh_before = max(refresh_before, 0) # 确保不会是负数585 586 print(f"[后台任务] 令牌将在 {int(remaining_time)} 秒后过期,计划在 {int(refresh_before)} 秒后刷新")587 await asyncio.sleep(refresh_before)588 589 # 刷新令牌590 print("[后台任务] 开始自动刷新令牌")591 await get_samba_token()592 593# 在应用启动时启动后台任务594@app.on_event("startup")595async def startup_event():596 asyncio.create_task(token_refresh_task())597 598if __name__ == "__main__":599 import uvicorn600 uvicorn.run(app, host="0.0.0.0", port=7860) 