CoolFace
Apppublic

nettw/SambaNova2API

sourceHugging Faceupdated 1y agoView on Hugging Face
0likes
app.py600 linesDownload Raw Back to root
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)