├── app ├── core │ ├── __init__.py │ └── config.py ├── providers │ ├── __init__.py │ ├── base_provider.py │ └── smithery_provider.py ├── services │ ├── session_manager.py │ └── tool_caller.py └── utils │ └── sse_utils.py ├── requirements.txt ├── docker-compose.yml ├── .env.example ├── Dockerfile ├── nginx.conf ├── main.py ├── .env ├── LICENSE ├── README.md └── 项目完整结构代码.txt /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn[standard] 3 | pydantic-settings 4 | python-dotenv 5 | cloudscraper 6 | cachetools 7 | httpx 8 | -------------------------------------------------------------------------------- /app/providers/base_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Any 3 | from fastapi.responses import StreamingResponse, JSONResponse 4 | 5 | class BaseProvider(ABC): 6 | @abstractmethod 7 | async def chat_completion( 8 | self, 9 | request_data: Dict[str, Any] 10 | ) -> StreamingResponse: 11 | pass 12 | 13 | @abstractmethod 14 | async def get_models(self) -> JSONResponse: 15 | pass 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | image: nginx:latest 4 | container_name: smithery-2api-nginx 5 | restart: always 6 | ports: 7 | - "${NGINX_PORT:-8088}:80" 8 | volumes: 9 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 10 | depends_on: 11 | - app 12 | networks: 13 | - smithery-net 14 | 15 | app: 16 | build: 17 | context: . 18 | dockerfile: Dockerfile 19 | container_name: smithery-2api-app 20 | restart: unless-stopped 21 | env_file: 22 | - .env 23 | networks: 24 | - smithery-net 25 | 26 | networks: 27 | smithery-net: 28 | driver: bridge 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ==================================================================== 2 | # smithery-2api 配置文件模板 3 | # ==================================================================== 4 | # 5 | # 请将此文件重命名为 ".env" 并填入您的凭证。 6 | # 7 | 8 | # --- 核心安全配置 (必须设置) --- 9 | # 用于保护您 API 服务的访问密钥。 10 | API_MASTER_KEY=sk-smithery-2api-default-key-please-change-me 11 | 12 | # --- 部署配置 (可选) --- 13 | # Nginx 对外暴露的端口 14 | NGINX_PORT=8088 15 | 16 | # --- Smithery.ai 凭证 (必须设置) --- 17 | # 请从浏览器开发者工具中获取完整的 Cookie 字符串。 18 | # 支持多账号轮询,只需按格式添加 SMITHERY_COOKIE_2, SMITHERY_COOKIE_3, ... 19 | SMITHERY_COOKIE_1="在此处粘贴您的完整 Cookie 字符串" 20 | 21 | # --- 会话管理 (可选) --- 22 | # 对话历史在内存中的缓存时间(秒),默认1小时 23 | SESSION_CACHE_TTL=3600 24 | -------------------------------------------------------------------------------- /app/services/session_manager.py: -------------------------------------------------------------------------------- 1 | from cachetools import TTLCache 2 | from typing import List, Dict, Any 3 | from app.core.config import settings 4 | 5 | class SessionManager: 6 | def __init__(self): 7 | self.cache = TTLCache(maxsize=1024, ttl=settings.SESSION_CACHE_TTL) 8 | 9 | def get_messages(self, session_id: str) -> List[Dict[str, Any]]: 10 | """ 11 | 从缓存中获取消息历史。 12 | 返回列表的副本以防止对缓存的意外修改。 13 | """ 14 | return self.cache.get(session_id, []).copy() 15 | 16 | def update_messages(self, session_id: str, messages: List[Dict[str, Any]]): 17 | """ 18 | 将更新后的消息历史存回缓存。 19 | """ 20 | self.cache[session_id] = messages 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ==================================================================== 2 | # Dockerfile for smithery-2api (v1.0 - Genesis Omega Edition) 3 | # ==================================================================== 4 | 5 | FROM python:3.10-slim 6 | 7 | # 设置环境变量 8 | ENV PYTHONDONTWRITEBYTECODE=1 9 | ENV PYTHONUNBUFFERED=1 10 | WORKDIR /app 11 | 12 | # 安装 Python 依赖 13 | COPY requirements.txt . 14 | RUN pip install --no-cache-dir --upgrade pip && \ 15 | pip install --no-cache-dir -r requirements.txt 16 | 17 | # 复制应用代码 18 | COPY . . 19 | 20 | # 创建并切换到非 root 用户 21 | RUN useradd --create-home appuser && \ 22 | chown -R appuser:appuser /app 23 | USER appuser 24 | 25 | # 暴露端口并启动 26 | EXPOSE 8000 27 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] 28 | -------------------------------------------------------------------------------- /app/utils/sse_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from typing import Dict, Any, Optional 4 | 5 | DONE_CHUNK = b"data: [DONE]\n\n" 6 | 7 | def create_sse_data(data: Dict[str, Any]) -> bytes: 8 | return f"data: {json.dumps(data)}\n\n".encode('utf-8') 9 | 10 | def create_chat_completion_chunk( 11 | request_id: str, 12 | model: str, 13 | content: str, 14 | finish_reason: Optional[str] = None 15 | ) -> Dict[str, Any]: 16 | return { 17 | "id": request_id, 18 | "object": "chat.completion.chunk", 19 | "created": int(time.time()), 20 | "model": model, 21 | "choices": [ 22 | { 23 | "index": 0, 24 | "delta": {"content": content}, 25 | "finish_reason": finish_reason 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | upstream smithery_backend { 9 | server app:8000; 10 | } 11 | 12 | server { 13 | listen 80; 14 | server_name localhost; 15 | 16 | location / { 17 | proxy_pass http://smithery_backend; 18 | proxy_set_header Host $host; 19 | proxy_set_header X-Real-IP $remote_addr; 20 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 21 | proxy_set_header X-Forwarded-Proto $scheme; 22 | 23 | proxy_buffering off; 24 | proxy_cache off; 25 | proxy_set_header Connection ''; 26 | proxy_http_version 1.1; 27 | chunked_transfer_encoding off; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import asynccontextmanager 3 | from typing import Optional 4 | 5 | from fastapi import FastAPI, Request, HTTPException, Depends, Header 6 | from fastapi.responses import JSONResponse, StreamingResponse 7 | 8 | from app.core.config import settings 9 | from app.providers.smithery_provider import SmitheryProvider 10 | 11 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 12 | logger = logging.getLogger(__name__) 13 | 14 | provider = SmitheryProvider() 15 | 16 | @asynccontextmanager 17 | async def lifespan(app: FastAPI): 18 | logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}") 19 | logger.info("服务已进入 'Cloudscraper' 模式,将自动处理 Cloudflare 挑战。") 20 | logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用") 21 | yield 22 | logger.info("应用关闭。") 23 | 24 | app = FastAPI( 25 | title=settings.APP_NAME, 26 | version=settings.APP_VERSION, 27 | description=settings.DESCRIPTION, 28 | lifespan=lifespan 29 | ) 30 | 31 | async def verify_api_key(authorization: Optional[str] = Header(None)): 32 | if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1": 33 | if not authorization or "bearer" not in authorization.lower(): 34 | raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。") 35 | token = authorization.split(" ")[-1] 36 | if token != settings.API_MASTER_KEY: 37 | raise HTTPException(status_code=403, detail="无效的 API Key。") 38 | 39 | @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) 40 | async def chat_completions(request: Request) -> StreamingResponse: 41 | try: 42 | request_data = await request.json() 43 | return await provider.chat_completion(request_data) 44 | except Exception as e: 45 | logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True) 46 | raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}") 47 | 48 | @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse) 49 | async def list_models(): 50 | return await provider.get_models() 51 | 52 | @app.get("/", summary="根路径") 53 | def root(): 54 | return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"} 55 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # [自动填充] smithery-2api 生产环境配置 2 | # 该文件由 Genesis Protocol · Ω (Omega) 版自动生成和修正 3 | 4 | # --- 安全配置 --- 5 | # 用于保护您的 API 服务的访问密钥,请按需修改为您自己的复杂密钥。 6 | API_MASTER_KEY=1 7 | 8 | # --- 端口配置 --- 9 | # Nginx 对外暴露的端口 10 | NGINX_PORT=8088 11 | 12 | # --- Smithery.ai 凭证 (支持多账号) --- 13 | # 格式已根据最终方案自动转换。请勿手动修改此 JSON 结构。 14 | # 您可以添加 SMITHERY_COOKIE_2, SMITHERY_COOKIE_3 等来启用轮询 15 | SMITHERY_COOKIE_1='{"access_token":"eyJhbGciOiJIUzI1NiIsImtpZCI6Ikk4N0N0U1U2UHFrWlVVV0QiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NwamF3YmZwd2V6amZtaWNvcHNsLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1OTA1ZjZiNC1kNzRmLTQ2YjQtOWI0Zi05ZGJiY2NiMjliZWUiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzYwNzkxMjY1LCJpYXQiOjE3NjA3ODc2NjUsImVtYWlsIjoiMjg2NDQ2MDQ1OUBxcS5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImdpdGh1YiIsInByb3ZpZGVycyI6WyJnaXRodWIiXX0sInVzZXJfbWV0YWRhdGEiOnsiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xMjg4ODAyMDY_dj00IiwiZW1haWwiOiIyODY0NDYwNDU5QHFxLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmdWxsX25hbWUiOiJDaGluZXNlLXRpbmdmZW5nIiwiaXNzIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbSIsIm5hbWUiOiJDaGluZXNlLXRpbmdmZW5nIiwicGhvbmVfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJsekE2IiwicHJvdmlkZXJfaWQiOiIxMjg4ODAyMDYiLCJzdWIiOiIxMjg4ODAyMDYiLCJ1c2VyX25hbWUiOiJsekE2In0sInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoib2F1dGgiLCJ0aW1lc3RhbXAiOjE3NTkxNjA1NDF9XSwic2Vzc2lvbl9pZCI6IjQxM2E0NTJjLTFjYjgtNDY5OC04YjYxLTQxYjQ3YjU5YjE4NyIsImlzX2Fub255bW91cyI6ZmFsc2V9.L4EcDMbtxobs_PpoPjpIfqvLxoIDyo_fFiLD4PyMwDo","token_type":"bearer","expires_in":3600,"expires_at":1760791265,"refresh_token":"4jxavtzs4tbw","user":{"id":"5905f6b4-d74f-46b4-9b4f-9dbbccb29bee","aud":"authenticated","role":"authenticated","email":"2864460459@qq.com","email_confirmed_at":"2025-09-29T15:42:18.953805Z","phone":"","confirmed_at":"2025-09-29T15:42:18.953805Z","last_sign_in_at":"2025-09-29T15:42:21.761683Z","app_metadata":{"provider":"github","providers":["github"]},"user_metadata":{"avatar_url":"https://avatars.githubusercontent.com/u/128880206?v=4","email":"2864460459@qq.com","email_verified":true,"full_name":"Chinese-tingfeng","iss":"https://api.github.com","name":"Chinese-tingfeng","phone_verified":false,"preferred_username":"lzA6","provider_id":"128880206","sub":"128880206","user_name":"lzA6"},"identities":[{"identity_id":"f3fd9077-2a8c-422d-b607-0a721f4ab6c2","id":"128880206","user_id":"5905f6b4-d74f-46b4-9b4f-9dbbccb29bee","identity_data":{"avatar_url":"https://avatars.githubusercontent.com/u/128880206?v=4","email":"2864460459@qq.com","email_verified":true,"full_name":"Chinese-tingfeng","iss":"https://api.github.com","name":"Chinese-tingfeng","phone_verified":false,"preferred_username":"lzA6","provider_id":"128880206","sub":"128880206","user_name":"lzA6"},"provider":"github","last_sign_in_at":"2025-09-29T15:42:18.9472Z","created_at":"2025-09-29T15:42:18.947275Z","updated_at":"2025-09-29T15:42:18.947275Z","email":"2864460459@qq.com"}],"created_at":"2025-09-29T15:42:18.943571Z","updated_at":"2025-10-18T11:41:04.939715Z","is_anonymous":false}}' 16 | 17 | # --- 会话管理 --- 18 | # 对话历史在内存中的缓存时间(秒),默认1小时 19 | SESSION_CACHE_TTL=3600 20 | -------------------------------------------------------------------------------- /app/services/tool_caller.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import random 4 | from typing import List, Dict, Any 5 | import cloudscraper 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | class ToolCaller: 10 | def __init__(self): 11 | self.mcp_url = "https://mcp.exa.ai/mcp" 12 | self.mcp_params = { 13 | "profile": "joyous-gull-NeZ2gW", 14 | "api_key": "fe5676be-931d-42e1-b5c9-90e94dce45ae" 15 | } 16 | self.scraper = cloudscraper.create_scraper() 17 | 18 | def get_tool_definitions(self) -> List[Dict[str, Any]]: 19 | # 从情报中提取的工具定义 20 | return [ 21 | {"name":"resolve-library-id","title":"Resolve Context7 Library ID","description":"...","inputSchema":{}}, 22 | {"name":"get-library-docs","title":"Get Library Docs","description":"...","inputSchema":{}}, 23 | {"name":"web_search_exa","description":"...","inputSchema":{}}, 24 | {"name":"get_code_context_exa","description":"...","inputSchema":{}} 25 | ] 26 | 27 | async def execute_tools(self, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 28 | # 此函数现在返回一个单一的用户消息,其中包含所有工具的结果 29 | all_results_content = "Tool results:\n" 30 | 31 | for call in tool_calls: 32 | function_call = call.get("function", {}) 33 | tool_name = function_call.get("name") 34 | tool_args_str = function_call.get("arguments", "{}") 35 | tool_call_id = call.get("id") 36 | 37 | logger.info(f"执行工具调用: {tool_name} with args {tool_args_str}") 38 | 39 | try: 40 | arguments = json.loads(tool_args_str) 41 | payload = { 42 | "method": "tools/call", 43 | "params": {"name": tool_name, "arguments": arguments}, 44 | "jsonrpc": "2.0", 45 | "id": random.randint(1, 100) 46 | } 47 | 48 | response = self.scraper.post(self.mcp_url, params=self.mcp_params, json=payload) 49 | response.raise_for_status() 50 | 51 | result_content = "No content returned." 52 | for line in response.iter_lines(): 53 | if line.startswith(b"data:"): 54 | content_str = line[len(b"data:"):].strip().decode('utf-8', errors='ignore') 55 | if content_str == "[DONE]": 56 | break 57 | try: 58 | data = json.loads(content_str) 59 | if "result" in data and "content" in data["result"]: 60 | # 将结果格式化为更易读的字符串 61 | result_content = json.dumps(data["result"]["content"], ensure_ascii=False, indent=2) 62 | break 63 | except json.JSONDecodeError: 64 | logger.warning(f"MCP tool: 无法解析 SSE 数据块: {content_str}") 65 | continue 66 | 67 | all_results_content += f"\n--- Result for {tool_name} (call_id: {tool_call_id}) ---\n{result_content}\n" 68 | 69 | except Exception as e: 70 | logger.error(f"工具调用失败: {e}", exc_info=True) 71 | error_str = f'{{"error": "Tool call failed", "details": "{str(e)}"}}' 72 | all_results_content += f"\n--- Error for {tool_name} (call_id: {tool_call_id}) ---\n{error_str}\n" 73 | 74 | # 返回一个单一的用户消息,而不是多个 tool 角色的消息 75 | return [{"role": "user", "content": all_results_content}] 76 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | from pydantic_settings import BaseSettings, SettingsConfigDict 5 | from typing import List, Optional, Dict 6 | 7 | # 获取一个日志记录器实例 8 | logger = logging.getLogger(__name__) 9 | 10 | class AuthCookie: 11 | """ 12 | 处理并生成 Smithery.ai 所需的认证 Cookie。 13 | 它将 .env 文件中的 JSON 字符串转换为一个标准的 HTTP Cookie 头部字符串。 14 | """ 15 | def __init__(self, json_string: str): 16 | try: 17 | # 1. 解析从 .env 文件读取的 JSON 字符串 18 | data = json.loads(json_string) 19 | self.access_token = data.get("access_token") 20 | self.refresh_token = data.get("refresh_token") 21 | self.expires_at = data.get("expires_at", 0) 22 | 23 | if not self.access_token: 24 | raise ValueError("Cookie JSON 中缺少 'access_token'") 25 | 26 | # 2. 构造将要放入 Cookie header 的值部分 (它本身也是一个 JSON) 27 | # 注意:这里我们只包含 Supabase auth 需要的核心字段 28 | cookie_value_data = { 29 | "access_token": self.access_token, 30 | "refresh_token": self.refresh_token, 31 | "token_type": data.get("token_type", "bearer"), 32 | "expires_in": data.get("expires_in", 3600), 33 | "expires_at": self.expires_at, 34 | "user": data.get("user") 35 | } 36 | 37 | # 3. 构造完整的 Cookie 键值对字符串 38 | # Smithery.ai 使用的 Supabase project_ref 是 'spjawbfpwezjfmicopsl' 39 | project_ref = "spjawbfpwezjfmicopsl" 40 | cookie_key = f"sb-{project_ref}-auth-token" 41 | # 将值部分转换为紧凑的 JSON 字符串 42 | cookie_value = json.dumps(cookie_value_data, separators=(',', ':')) 43 | 44 | # 最终用于 HTTP Header 的字符串,格式为 "key=value" 45 | self.header_cookie_string = f"{cookie_key}={cookie_value}" 46 | 47 | except json.JSONDecodeError as e: 48 | raise ValueError(f"无法从提供的字符串中解析认证 JSON: {e}") 49 | except Exception as e: 50 | raise ValueError(f"初始化 AuthCookie 时出错: {e}") 51 | 52 | def __repr__(self): 53 | return f"" 54 | 55 | 56 | class Settings(BaseSettings): 57 | model_config = SettingsConfigDict( 58 | env_file=".env", 59 | env_file_encoding='utf-8', 60 | extra="ignore" 61 | ) 62 | 63 | APP_NAME: str = "smithery-2api" 64 | APP_VERSION: str = "1.0.0" 65 | DESCRIPTION: str = "一个将 smithery.ai 转换为兼容 OpenAI 格式 API 的高性能代理,支持多账号、上下文和工具调用。" 66 | 67 | CHAT_API_URL: str = "https://smithery.ai/api/chat" 68 | TOKEN_REFRESH_URL: str = "https://spjawbfpwezjfmicopsl.supabase.co/auth/v1/token?grant_type=refresh_token" 69 | SUPABASE_API_KEY: str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNwamF3YmZwd2V6amZtaWNvcHNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQxNDc0MDUsImV4cCI6MjA0OTcyMzQwNX0.EBIg7_F2FZh4KZ3UNwZdBRjpp2fgHqXGJOvOSQ053MU" 70 | 71 | API_MASTER_KEY: Optional[str] = None 72 | 73 | AUTH_COOKIES: List[AuthCookie] = [] 74 | 75 | API_REQUEST_TIMEOUT: int = 180 76 | NGINX_PORT: int = 8088 77 | SESSION_CACHE_TTL: int = 3600 78 | 79 | KNOWN_MODELS: List[str] = [ 80 | "claude-haiku-4.5", "claude-sonnet-4.5", "gpt-5", "gpt-5-mini", 81 | "gpt-5-nano", "gemini-2.5-flash-lite", "gemini-2.5-pro", "glm-4.6", 82 | "grok-4-fast-non-reasoning", "grok-4-fast-reasoning", "kimi-k2", "deepseek-reasoner" 83 | ] 84 | 85 | def __init__(self, **values): 86 | super().__init__(**values) 87 | # 从环境变量 SMITHERY_COOKIE_1, SMITHERY_COOKIE_2, ... 加载 cookies 88 | i = 1 89 | while True: 90 | cookie_str = os.getenv(f"SMITHERY_COOKIE_{i}") 91 | if cookie_str: 92 | try: 93 | # 使用 AuthCookie 类来解析和处理 cookie 字符串 94 | self.AUTH_COOKIES.append(AuthCookie(cookie_str)) 95 | except ValueError as e: 96 | logger.warning(f"无法加载或解析 SMITHERY_COOKIE_{i}: {e}") 97 | i += 1 98 | else: 99 | break 100 | 101 | if not self.AUTH_COOKIES: 102 | raise ValueError("必须在 .env 文件中至少配置一个有效的 SMITHERY_COOKIE_1") 103 | 104 | settings = Settings() 105 | -------------------------------------------------------------------------------- /app/providers/smithery_provider.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import logging 4 | import uuid 5 | import random 6 | import cloudscraper 7 | from typing import Dict, Any, AsyncGenerator, List 8 | 9 | from fastapi import HTTPException 10 | from fastapi.responses import StreamingResponse, JSONResponse 11 | 12 | from app.core.config import settings 13 | from app.providers.base_provider import BaseProvider 14 | # 移除了不再使用的 SessionManager 15 | # from app.services.session_manager import SessionManager 16 | from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK 17 | 18 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - [%(levelname)s] - %(message)s') 19 | logger = logging.getLogger(__name__) 20 | 21 | class SmitheryProvider(BaseProvider): 22 | def __init__(self): 23 | # self.session_manager = SessionManager() # 移除会话管理器 24 | self.scraper = cloudscraper.create_scraper() 25 | self.cookie_index = 0 26 | 27 | def _get_cookie(self) -> str: 28 | """从配置中轮换获取一个格式正确的 Cookie 字符串。""" 29 | auth_cookie_obj = settings.AUTH_COOKIES[self.cookie_index] 30 | self.cookie_index = (self.cookie_index + 1) % len(settings.AUTH_COOKIES) 31 | return auth_cookie_obj.header_cookie_string 32 | 33 | def _convert_messages_to_smithery_format(self, openai_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 34 | """ 35 | 将客户端发来的 OpenAI 格式消息列表转换为 Smithery.ai 后端所需的格式。 36 | 例如: {"role": "user", "content": "你好"} -> {"role": "user", "parts": [{"type": "text", "text": "你好"}]} 37 | """ 38 | smithery_messages = [] 39 | for msg in openai_messages: 40 | role = msg.get("role") 41 | content = msg.get("content", "") 42 | 43 | # 忽略格式不正确或内容为空的消息 44 | if not role or not isinstance(content, str): 45 | continue 46 | 47 | smithery_messages.append({ 48 | "role": role, 49 | "parts": [{"type": "text", "text": content}], 50 | "id": f"msg-{uuid.uuid4().hex[:16]}" 51 | }) 52 | return smithery_messages 53 | 54 | async def chat_completion(self, request_data: Dict[str, Any]) -> StreamingResponse: 55 | """ 56 | 处理聊天补全请求。 57 | 此实现为无状态模式,完全依赖客户端发送的完整对话历史。 58 | """ 59 | 60 | # 1. 直接从客户端请求中获取完整的消息历史 61 | messages_from_client = request_data.get("messages", []) 62 | 63 | # 2. 将其转换为 Smithery.ai 后端所需的格式 64 | smithery_formatted_messages = self._convert_messages_to_smithery_format(messages_from_client) 65 | 66 | async def stream_generator() -> AsyncGenerator[bytes, None]: 67 | request_id = f"chatcmpl-{uuid.uuid4()}" 68 | model = request_data.get("model", "claude-haiku-4.5") 69 | 70 | try: 71 | # 3. 使用转换后的消息列表准备请求体 72 | payload = self._prepare_payload(model, smithery_formatted_messages) 73 | headers = self._prepare_headers() 74 | 75 | logger.info("===================== [REQUEST TO SMITHERY (Stateless)] =====================") 76 | logger.info(f"URL: POST {settings.CHAT_API_URL}") 77 | logger.info(f"PAYLOAD:\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 78 | logger.info("=====================================================================================") 79 | 80 | # 使用 cloudscraper 发送请求 81 | response = self.scraper.post( 82 | settings.CHAT_API_URL, 83 | headers=headers, 84 | json=payload, 85 | stream=True, 86 | timeout=settings.API_REQUEST_TIMEOUT 87 | ) 88 | 89 | if response.status_code != 200: 90 | logger.error("==================== [RESPONSE FROM SMITHERY (ERROR)] ===================") 91 | logger.error(f"STATUS CODE: {response.status_code}") 92 | logger.error(f"RESPONSE BODY:\n{response.text}") 93 | logger.error("=================================================================") 94 | 95 | response.raise_for_status() 96 | 97 | # 4. 流式处理返回的数据 (此部分逻辑不变) 98 | for line in response.iter_lines(): 99 | if line.startswith(b"data:"): 100 | content = line[len(b"data:"):].strip() 101 | if content == b"[DONE]": 102 | break 103 | try: 104 | data = json.loads(content) 105 | if data.get("type") == "text-delta": 106 | delta_content = data.get("delta", "") 107 | chunk = create_chat_completion_chunk(request_id, model, delta_content) 108 | yield create_sse_data(chunk) 109 | except json.JSONDecodeError: 110 | if content: 111 | logger.warning(f"无法解析 SSE 数据块: {content}") 112 | continue 113 | 114 | # 5. 无状态模式下,无需保存任何会话,直接发送结束标志 115 | final_chunk = create_chat_completion_chunk(request_id, model, "", "stop") 116 | yield create_sse_data(final_chunk) 117 | yield DONE_CHUNK 118 | 119 | except Exception as e: 120 | logger.error(f"处理流时发生错误: {e}", exc_info=True) 121 | error_message = f"内部服务器错误: {str(e)}" 122 | error_chunk = create_chat_completion_chunk(request_id, model, error_message, "stop") 123 | yield create_sse_data(error_chunk) 124 | yield DONE_CHUNK 125 | 126 | return StreamingResponse(stream_generator(), media_type="text/event-stream") 127 | 128 | def _prepare_headers(self) -> Dict[str, str]: 129 | # 包含我们之前分析出的所有必要请求头 130 | return { 131 | "Accept": "*/*", 132 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 133 | "Content-Type": "application/json", 134 | "Cookie": self._get_cookie(), 135 | "Origin": "https://smithery.ai", 136 | "Referer": "https://smithery.ai/playground", 137 | "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", 138 | "priority": "u=1, i", 139 | "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 140 | "sec-ch-ua-mobile": "?0", 141 | "sec-ch-ua-platform": '"Windows"', 142 | "sec-fetch-dest": "empty", 143 | "sec-fetch-mode": "cors", 144 | "sec-fetch-site": "same-origin", 145 | "x-posthog-distinct-id": "5905f6b4-d74f-46b4-9b4f-9dbbccb29bee", 146 | "x-posthog-session-id": "0199f71f-8c42-7f9a-ba3a-ff5999dd444a", 147 | "x-posthog-window-id": "0199f71f-8c42-7f9a-ba3a-ff5ab5b20a8e", 148 | } 149 | 150 | def _prepare_payload(self, model: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]: 151 | return { 152 | "messages": messages, 153 | "tools": [], 154 | "model": model, 155 | "systemPrompt": "You are a helpful assistant." 156 | } 157 | 158 | async def get_models(self) -> JSONResponse: 159 | model_data = { 160 | "object": "list", 161 | "data": [ 162 | {"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"} 163 | for name in settings.KNOWN_MODELS 164 | ] 165 | } 166 | return JSONResponse(content=model_data) 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 🤖 smithery-2api 🤖 4 | 5 | **将 [Smithery.ai](https://smithery.ai/) 强大的 AI 模型能力无缝转换为兼容 OpenAI API 格式的转换器** 6 | 7 | [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 8 | [![Python Version](https://img.shields.io/badge/python-3.10+-brightgreen.svg)](https://www.python.org/) 9 | [![Docker Support](https://img.shields.io/badge/docker-supported-blue.svg?logo=docker)](https://www.docker.com/) 10 | [![GitHub Repo stars](https://img.shields.io/github/stars/lzA6/smithery-2api?style=social)](https://github.com/lzA6/smithery-2api) 11 | 12 |
13 | 14 | > "任何足够先进的技术,都与魔法无异。" —— 亚瑟·克拉克 15 | > 16 | > 我们不创造魔法,我们只是让每个人都能成为魔法师。`smithery-2api` 的诞生,源于一个简单的信念:强大的工具应该被更广泛、更便捷地使用。我们希望通过这个项目,打破平台的壁垒,将 Smithery.ai 先进的模型能力,注入到每一个支持 OpenAI API 的应用生态中。 17 | 18 | --- 19 | 20 | ## ✨ 核心特性 21 | 22 | * **🚀 零成本接入** - 免费将 Smithery.ai 的多种模型接入现有 OpenAI 生态 23 | * **🔌 高度兼容** - 完全模拟 OpenAI 的 `/v1/chat/completions` 和 `/v1/models` 接口 24 | * **🔄 多账号轮询** - 支持配置多个 Smithery.ai 账号,自动轮询提高稳定性 25 | * **💨 无状态设计** - 极致轻量,易于水平扩展,保护用户隐私 26 | * **☁️ 穿透 Cloudflare** - 内置自动处理 Cloudflare 防护机制 27 | * **📦 Docker 一键部署** - 一条命令即可启动服务 28 | * **🔓 开源自由** - 采用 Apache 2.0 协议,自由使用、修改和分发 29 | 30 | --- 31 | 32 | ## 🏗️ 架构设计 33 | 34 | ### 核心工作原理 35 | 36 | `smithery-2api` 充当一个智能的协议转换器,在 OpenAI API 格式和 Smithery.ai 内部 API 格式之间进行实时转换。 37 | 38 | ```mermaid 39 | graph TB 40 | Client[第三方应用] -->|OpenAI 格式请求| API[smithery-2api] 41 | API -->|协议转换| Translator[API 转换器] 42 | Translator -->|身份伪装| Identity[身份伪装模块] 43 | Identity -->|Smithery 格式请求| Smithery[Smithery.ai 服务] 44 | Smithery -->|流式响应| API 45 | API -->|OpenAI 格式响应| Client 46 | 47 | subgraph "转换过程详解" 48 | Translator --> Format[格式转换] 49 | Identity --> Headers[请求头模拟] 50 | Identity --> Cookie[Cookie 处理] 51 | Identity --> Cloudflare[Cloudflare 穿透] 52 | end 53 | ``` 54 | 55 | ### 技术实现细节 56 | 57 | #### 1. API 格式转换 58 | 59 | **技术核心**: `app/providers/smithery_provider.py` 中的 `_convert_messages_to_smithery_format` 方法 60 | 61 | **转换示例**: 62 | 63 | **输入 (OpenAI 格式)**: 64 | ```json 65 | { 66 | "model": "gpt-4", 67 | "messages": [ 68 | {"role": "user", "content": "你好,请介绍一下自己"} 69 | ], 70 | "stream": true 71 | } 72 | ``` 73 | 74 | **输出 (Smithery.ai 格式)**: 75 | ```json 76 | { 77 | "model": "gpt-4", 78 | "messages": [ 79 | { 80 | "role": "user", 81 | "parts": [{"type": "text", "text": "你好,请介绍一下自己"}], 82 | "id": "msg-xxxxxxxx" 83 | } 84 | ], 85 | "stream": true 86 | } 87 | ``` 88 | 89 | #### 2. 身份认证与伪装 90 | 91 | **技术核心**: `app/core/config.py` 中的 `AuthCookie` 类 92 | 93 | ```python 94 | class AuthCookie(BaseModel): 95 | """Smithery 认证 Cookie 数据结构""" 96 | access_token: str 97 | token_type: str = "bearer" 98 | expires_in: int 99 | refresh_token: str 100 | user: Dict[str, Any] 101 | 102 | @property 103 | def header_cookie_string(self) -> str: 104 | """生成请求头中的 Cookie 字符串""" 105 | return f"sb-access-token={self.access_token}; sb-refresh-token={self.refresh_token}" 106 | ``` 107 | 108 | #### 3. 流式响应处理 109 | 110 | **技术核心**: `app/utils/sse_utils.py` 和流式响应处理逻辑 111 | 112 | ```python 113 | async def handle_stream_response(response, model: str): 114 | """处理流式响应并转换为 OpenAI 格式""" 115 | async for line in response.iter_lines(): 116 | if line.startswith('data: '): 117 | data = line[6:] # 移除 'data: ' 前缀 118 | if data == '[DONE]': 119 | yield create_chat_completion_chunk("", "", finish_reason="stop") 120 | break 121 | # 解析和转换数据... 122 | yield f"data: {json.dumps(converted_data)}\n\n" 123 | ``` 124 | 125 | --- 126 | 127 | ## 🚀 快速开始 128 | 129 | ### 环境要求 130 | 131 | - **Git** - 版本控制工具 132 | - **Docker & Docker Compose** - 容器化部署 133 | 134 | ### 部署步骤 135 | 136 | #### 步骤 1: 获取项目代码 137 | 138 | ```bash 139 | git clone https://github.com/lzA6/smithery-2api.git 140 | cd smithery-2api 141 | ``` 142 | 143 | #### 步骤 2: 获取认证信息 144 | 145 | 1. 在浏览器中登录 [Smithery.ai](https://smithery.ai/) 146 | 2. 打开开发者工具 (F12) 147 | 3. 切换到 **Application** → **Local Storage** → `https://smithery.ai` 148 | 4. 找到 key 为 `sb-spjawbfpwezjfmicopsl-auth-token` 的项 149 | 5. 复制完整的 value 值(JSON 格式) 150 | 151 | ![获取 Cookie 演示](https://i.imgur.com/aY2i1sA.gif) 152 | 153 | #### 步骤 3: 配置环境变量 154 | 155 | ```bash 156 | # 复制环境配置模板 157 | cp .env.example .env 158 | 159 | # 编辑配置文件 160 | vim .env 161 | ``` 162 | 163 | **环境变量配置示例**: 164 | ```env 165 | # API 主密钥(用于客户端认证) 166 | API_MASTER_KEY="your-secure-master-key-here" 167 | 168 | # Smithery.ai 认证信息(支持多个账号) 169 | SMITHERY_COOKIE_1='{"access_token":"eyJ...","token_type":"bearer","expires_in":3600,...}' 170 | SMITHERY_COOKIE_2='{"access_token":"eyJ...","token_type":"bearer","expires_in":3600,...}' 171 | 172 | # 服务端口配置 173 | NGINX_PORT=8088 174 | APP_PORT=8000 175 | ``` 176 | 177 | #### 步骤 4: 启动服务 178 | 179 | ```bash 180 | docker-compose up -d 181 | ``` 182 | 183 | #### 步骤 5: 验证部署 184 | 185 | 使用 curl 测试服务是否正常运行: 186 | 187 | ```bash 188 | curl -X GET "http://localhost:8088/v1/models" \ 189 | -H "Authorization: Bearer your-secure-master-key-here" 190 | ``` 191 | 192 | ### 客户端配置示例 193 | 194 | **OpenAI 官方客户端**: 195 | ```python 196 | from openai import OpenAI 197 | 198 | client = OpenAI( 199 | base_url="http://localhost:8088/v1", 200 | api_key="your-secure-master-key-here" 201 | ) 202 | 203 | response = client.chat.completions.create( 204 | model="gpt-4", 205 | messages=[{"role": "user", "content": "Hello, world!"}], 206 | stream=True 207 | ) 208 | ``` 209 | 210 | **第三方应用配置**: 211 | - **Base URL**: `http://localhost:8088/v1` 212 | - **API Key**: `your-secure-master-key-here` 213 | - **Model**: 任意支持的模型名称 214 | 215 | --- 216 | 217 | ## 🛠️ 技术架构 218 | 219 | ### 技术栈 220 | 221 | | 技术组件 | 版本 | 用途 | 关键特性 | 222 | |---------|------|------|----------| 223 | | **FastAPI** | 0.104+ | Web 框架 | 异步支持,自动文档生成 | 224 | | **Pydantic** | 2.5+ | 数据验证 | 类型提示,配置管理 | 225 | | **Cloudscraper** | 1.2+ | 反爬虫绕过 | Cloudflare 穿透 | 226 | | **Uvicorn** | 0.24+ | ASGI 服务器 | 高性能异步服务器 | 227 | | **Docker** | 20.10+ | 容器化 | 环境隔离,一键部署 | 228 | | **Nginx** | 1.24+ | 反向代理 | 负载均衡,静态文件服务 | 229 | 230 | ### 项目结构 231 | 232 | ``` 233 | smithery-2api/ 234 | ├── 📁 app/ # 应用核心代码 235 | │ ├── 📁 core/ # 核心模块 236 | │ │ ├── __init__.py 237 | │ │ └── config.py # 配置管理,环境变量处理 238 | │ ├── 📁 providers/ # 服务提供商模块 239 | │ │ ├── __init__.py 240 | │ │ ├── base_provider.py # 提供商基类 241 | │ │ └── smithery_provider.py # Smithery.ai 提供商实现 242 | │ ├── 📁 services/ # 业务服务层 243 | │ │ ├── session_manager.py # 会话管理(预留) 244 | │ │ └── tool_caller.py # 工具调用(预留) 245 | │ └── 📁 utils/ # 工具函数 246 | │ └── sse_utils.py # Server-Sent Events 工具 247 | ├── 📄 main.py # FastAPI 应用入口 248 | ├── 📄 nginx.conf # Nginx 配置 249 | ├── 📄 Dockerfile # 应用镜像构建配置 250 | ├── 📄 docker-compose.yml # 服务编排配置 251 | ├── 📄 requirements.txt # Python 依赖 252 | └── 📄 .env.example # 环境变量模板 253 | ``` 254 | 255 | ### 核心模块详解 256 | 257 | #### 1. 配置管理 (`app/core/config.py`) 258 | 259 | ```python 260 | class Settings(BaseSettings): 261 | """应用配置类""" 262 | API_MASTER_KEY: str 263 | SMITHERY_COOKIE_1: Optional[str] = None 264 | SMITHERY_COOKIE_2: Optional[str] = None 265 | NGINX_PORT: int = 8088 266 | APP_PORT: int = 8000 267 | 268 | @property 269 | def AUTH_COOKIES(self) -> List[AuthCookie]: 270 | """获取所有可用的认证 Cookie""" 271 | cookies = [] 272 | for i in range(1, 3): 273 | if cookie_str := getattr(self, f"SMITHERY_COOKIE_{i}", None): 274 | try: 275 | cookies.append(AuthCookie.parse_raw(cookie_str)) 276 | except ValidationError as e: 277 | logger.warning(f"Invalid cookie format for SMITHERY_COOKIE_{i}: {e}") 278 | return cookies 279 | 280 | settings = Settings() 281 | ``` 282 | 283 | #### 2. Smithery 提供商 (`app/providers/smithery_provider.py`) 284 | 285 | ```python 286 | class SmitheryProvider(BaseProvider): 287 | """Smithery.ai 服务提供商""" 288 | 289 | async def chat_completion(self, request: ChatCompletionRequest) -> StreamingResponse: 290 | """处理聊天补全请求""" 291 | 292 | # 1. 轮询获取可用 Cookie 293 | auth_cookie = self._get_cookie() 294 | 295 | # 2. 准备请求头和负载 296 | headers = self._prepare_headers(auth_cookie) 297 | payload = self._prepare_payload(request) 298 | 299 | # 3. 发送请求到 Smithery.ai 300 | response = self._make_request(headers, payload) 301 | 302 | # 4. 处理流式响应 303 | return self._handle_stream_response(response, request.model) 304 | 305 | def _convert_messages_to_smithery_format(self, messages: List[Dict]) -> List[Dict]: 306 | """将 OpenAI 消息格式转换为 Smithery 格式""" 307 | converted_messages = [] 308 | for msg in messages: 309 | converted_msg = { 310 | "role": msg["role"], 311 | "parts": [{"type": "text", "text": msg["content"]}], 312 | "id": f"msg-{str(uuid.uuid4())[:8]}" 313 | } 314 | converted_messages.append(converted_msg) 315 | return converted_messages 316 | ``` 317 | 318 | --- 319 | 320 | ## 🔧 高级配置 321 | 322 | ### 多账号负载均衡 323 | 324 | 支持配置多个 Smithery.ai 账号实现自动轮询: 325 | 326 | ```env 327 | # 配置多个账号提高可用性 328 | SMITHERY_COOKIE_1='{"access_token":"token1","refresh_token":"refresh1",...}' 329 | SMITHERY_COOKIE_2='{"access_token":"token2","refresh_token":"refresh2",...}' 330 | SMITHERY_COOKIE_3='{"access_token":"token3","refresh_token":"refresh3",...}' 331 | ``` 332 | 333 | ### 自定义模型映射 334 | 335 | 在 `smithery_provider.py` 中配置模型映射关系: 336 | 337 | ```python 338 | MODEL_MAPPING = { 339 | "gpt-4": "gpt-4", 340 | "gpt-3.5-turbo": "claude-haiku-4.5", 341 | "claude-3-opus": "claude-opus-3.0" 342 | } 343 | ``` 344 | 345 | ### Nginx 优化配置 346 | 347 | ```nginx 348 | # nginx.conf 349 | server { 350 | listen 8088; 351 | client_max_body_size 100M; 352 | client_body_timeout 300s; 353 | 354 | location / { 355 | proxy_pass http://app:8000; 356 | proxy_set_header Host $host; 357 | proxy_set_header X-Real-IP $remote_addr; 358 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 359 | 360 | # 流式响应相关配置 361 | proxy_buffering off; 362 | proxy_cache off; 363 | proxy_read_timeout 300s; 364 | } 365 | } 366 | ``` 367 | 368 | --- 369 | 370 | ## 🐛 故障排除 371 | 372 | ### 常见问题 373 | 374 | **1. 认证失败** 375 | ``` 376 | 错误信息: Authentication failed or cookie expired 377 | 解决方案: 重新获取 SMITHERY_COOKIE 值 378 | ``` 379 | 380 | **2. Cloudflare 拦截** 381 | ``` 382 | 错误信息: 403 Forbidden or Cloudflare challenge 383 | 解决方案: 确保 cloudscraper 版本最新,或更新请求头信息 384 | ``` 385 | 386 | **3. 流式响应中断** 387 | ``` 388 | 错误信息: 连接提前关闭 389 | 解决方案: 检查客户端超时设置,确保网络稳定性 390 | ``` 391 | 392 | ### 日志调试 393 | 394 | 启用详细日志输出: 395 | 396 | ```python 397 | import logging 398 | logging.basicConfig(level=logging.DEBUG) 399 | ``` 400 | 401 | ### 健康检查 402 | 403 | ```bash 404 | # 检查服务状态 405 | curl -X GET "http://localhost:8088/health" 406 | 407 | # 检查模型列表 408 | curl -X GET "http://localhost:8088/v1/models" \ 409 | -H "Authorization: Bearer your-api-key" 410 | ``` 411 | 412 | --- 413 | 414 | ## 🚧 限制与待完善功能 415 | 416 | ### 当前限制 417 | 418 | 1. **令牌过期处理** - 当前未实现自动刷新机制 419 | 2. **错误处理** - 错误信息返回可以更加友好 420 | 3. **速率限制** - 缺少请求频率限制 421 | 4. **会话管理** - 无状态设计,客户端需维护完整上下文 422 | 423 | ### 开发路线图 424 | 425 | - [ ] **自动令牌刷新机制** 426 | - 利用 refresh_token 自动更新 access_token 427 | - 实现令牌过期前预刷新 428 | 429 | - [ ] **增强的错误处理** 430 | - 更友好的错误消息 431 | - 重试机制和故障转移 432 | 433 | - [ ] **会话状态管理** 434 | - 可选的有状态会话模式 435 | - Redis 后端支持 436 | 437 | - [ ] **监控和指标** 438 | - Prometheus 指标收集 439 | - 请求统计和性能监控 440 | 441 | - [ ] **扩展提供商支持** 442 | - 支持其他 AI 服务平台 443 | - 统一的提供商接口 444 | 445 | --- 446 | 447 | ## 🤝 贡献指南 448 | 449 | 我们欢迎各种形式的贡献! 450 | 451 | ### 报告问题 452 | 453 | 如果您发现任何问题,请通过 [GitHub Issues](https://github.com/lzA6/smithery-2api/issues) 报告。 454 | 455 | ### 代码贡献 456 | 457 | 1. Fork 本仓库 458 | 2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) 459 | 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) 460 | 4. 推送到分支 (`git push origin feature/AmazingFeature`) 461 | 5. 开启 Pull Request 462 | 463 | ### 开发环境设置 464 | 465 | ```bash 466 | # 克隆仓库 467 | git clone https://github.com/lzA6/smithery-2api.git 468 | cd smithery-2api 469 | 470 | # 安装依赖 471 | pip install -r requirements.txt 472 | 473 | # 设置环境变量 474 | cp .env.example .env 475 | # 编辑 .env 文件配置认证信息 476 | 477 | # 启动开发服务器 478 | uvicorn main:app --reload --port 8000 479 | ``` 480 | 481 | --- 482 | 483 | ## 📄 许可证 484 | 485 | 本项目采用 Apache 2.0 许可证 - 详见 [LICENSE](LICENSE) 文件。 486 | 487 | ## 🙏 致谢 488 | 489 | 感谢所有为这个项目做出贡献的开发者,以及 Smithery.ai 提供的优秀 AI 服务。 490 | 491 | --- 492 | 493 | ## 🔗 有用链接 494 | 495 | - [Smithery.ai 官网](https://smithery.ai/) 496 | - [OpenAI API 文档](https://platform.openai.com/docs/api-reference) 497 | - [FastAPI 文档](https://fastapi.tiangolo.com/) 498 | - [Docker 文档](https://docs.docker.com/) 499 | 500 | --- 501 | 502 |
503 | 504 | **如果这个项目对您有帮助,请给个 ⭐️ 支持一下!** 505 | 506 |
507 | -------------------------------------------------------------------------------- /项目完整结构代码.txt: -------------------------------------------------------------------------------- 1 | 项目 'smithery-2api' 的结构树: 2 | 📂 smithery-2api/ 3 | 📄 .env 4 | 📄 .env.example 5 | 📄 Dockerfile 6 | 📄 docker-compose.yml 7 | 📄 main.py 8 | 📄 nginx.conf 9 | 📄 requirements.txt 10 | 📂 app/ 11 | 📂 core/ 12 | 📄 __init__.py 13 | 📄 config.py 14 | 📂 providers/ 15 | 📄 __init__.py 16 | 📄 base_provider.py 17 | 📄 smithery_provider.py 18 | 📂 services/ 19 | 📄 session_manager.py 20 | 📄 tool_caller.py 21 | 📂 utils/ 22 | 📄 sse_utils.py 23 | ================================================================================ 24 | 25 | --- 文件路径: .env --- 26 | 27 | # [自动填充] smithery-2api 生产环境配置 28 | # 该文件由 Genesis Protocol · Ω (Omega) 版自动生成和修正 29 | 30 | # --- 安全配置 --- 31 | # 用于保护您的 API 服务的访问密钥,请按需修改为您自己的复杂密钥。 32 | API_MASTER_KEY=1 33 | 34 | # --- 端口配置 --- 35 | # Nginx 对外暴露的端口 36 | NGINX_PORT=8088 37 | 38 | # --- Smithery.ai 凭证 (支持多账号) --- 39 | # 格式已根据最终方案自动转换。请勿手动修改此 JSON 结构。 40 | # 您可以添加 SMITHERY_COOKIE_2, SMITHERY_COOKIE_3 等来启用轮询 41 | SMITHERY_COOKIE_1='{"access_token":"eyJhbGciOiJIUzI1NiIsImtpZCI6Ikk4N0N0U1U2UHFrWlVVV0QiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NwamF3YmZwd2V6amZtaWNvcHNsLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1OTA1ZjZiNC1kNzRmLTQ2YjQtOWI0Zi05ZGJiY2NiMjliZWUiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzYwNzkxMjY1LCJpYXQiOjE3NjA3ODc2NjUsImVtYWlsIjoiMjg2NDQ2MDQ1OUBxcS5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImdpdGh1YiIsInByb3ZpZGVycyI6WyJnaXRodWIiXX0sInVzZXJfbWV0YWRhdGEiOnsiYXZhdGFyX3VybCI6Imh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS8xMjg4ODAyMDY_dj00IiwiZW1haWwiOiIyODY0NDYwNDU5QHFxLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmdWxsX25hbWUiOiJDaGluZXNlLXRpbmdmZW5nIiwiaXNzIjoiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbSIsIm5hbWUiOiJDaGluZXNlLXRpbmdmZW5nIiwicGhvbmVfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJsekE2IiwicHJvdmlkZXJfaWQiOiIxMjg4ODAyMDYiLCJzdWIiOiIxMjg4ODAyMDYiLCJ1c2VyX25hbWUiOiJsekE2In0sInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoib2F1dGgiLCJ0aW1lc3RhbXAiOjE3NTkxNjA1NDF9XSwic2Vzc2lvbl9pZCI6IjQxM2E0NTJjLTFjYjgtNDY5OC04YjYxLTQxYjQ3YjU5YjE4NyIsImlzX2Fub255bW91cyI6ZmFsc2V9.L4EcDMbtxobs_PpoPjpIfqvLxoIDyo_fFiLD4PyMwDo","token_type":"bearer","expires_in":3600,"expires_at":1760791265,"refresh_token":"4jxavtzs4tbw","user":{"id":"5905f6b4-d74f-46b4-9b4f-9dbbccb29bee","aud":"authenticated","role":"authenticated","email":"2864460459@qq.com","email_confirmed_at":"2025-09-29T15:42:18.953805Z","phone":"","confirmed_at":"2025-09-29T15:42:18.953805Z","last_sign_in_at":"2025-09-29T15:42:21.761683Z","app_metadata":{"provider":"github","providers":["github"]},"user_metadata":{"avatar_url":"https://avatars.githubusercontent.com/u/128880206?v=4","email":"2864460459@qq.com","email_verified":true,"full_name":"Chinese-tingfeng","iss":"https://api.github.com","name":"Chinese-tingfeng","phone_verified":false,"preferred_username":"lzA6","provider_id":"128880206","sub":"128880206","user_name":"lzA6"},"identities":[{"identity_id":"f3fd9077-2a8c-422d-b607-0a721f4ab6c2","id":"128880206","user_id":"5905f6b4-d74f-46b4-9b4f-9dbbccb29bee","identity_data":{"avatar_url":"https://avatars.githubusercontent.com/u/128880206?v=4","email":"2864460459@qq.com","email_verified":true,"full_name":"Chinese-tingfeng","iss":"https://api.github.com","name":"Chinese-tingfeng","phone_verified":false,"preferred_username":"lzA6","provider_id":"128880206","sub":"128880206","user_name":"lzA6"},"provider":"github","last_sign_in_at":"2025-09-29T15:42:18.9472Z","created_at":"2025-09-29T15:42:18.947275Z","updated_at":"2025-09-29T15:42:18.947275Z","email":"2864460459@qq.com"}],"created_at":"2025-09-29T15:42:18.943571Z","updated_at":"2025-10-18T11:41:04.939715Z","is_anonymous":false}}' 42 | 43 | # --- 会话管理 --- 44 | # 对话历史在内存中的缓存时间(秒),默认1小时 45 | SESSION_CACHE_TTL=3600 46 | 47 | 48 | --- 文件路径: .env.example --- 49 | 50 | # ==================================================================== 51 | # smithery-2api 配置文件模板 52 | # ==================================================================== 53 | # 54 | # 请将此文件重命名为 ".env" 并填入您的凭证。 55 | # 56 | 57 | # --- 核心安全配置 (必须设置) --- 58 | # 用于保护您 API 服务的访问密钥。 59 | API_MASTER_KEY=sk-smithery-2api-default-key-please-change-me 60 | 61 | # --- 部署配置 (可选) --- 62 | # Nginx 对外暴露的端口 63 | NGINX_PORT=8088 64 | 65 | # --- Smithery.ai 凭证 (必须设置) --- 66 | # 请从浏览器开发者工具中获取完整的 Cookie 字符串。 67 | # 支持多账号轮询,只需按格式添加 SMITHERY_COOKIE_2, SMITHERY_COOKIE_3, ... 68 | SMITHERY_COOKIE_1="在此处粘贴您的完整 Cookie 字符串" 69 | 70 | # --- 会话管理 (可选) --- 71 | # 对话历史在内存中的缓存时间(秒),默认1小时 72 | SESSION_CACHE_TTL=3600 73 | 74 | 75 | --- 文件路径: Dockerfile --- 76 | 77 | # ==================================================================== 78 | # Dockerfile for smithery-2api (v1.0 - Genesis Omega Edition) 79 | # ==================================================================== 80 | 81 | FROM python:3.10-slim 82 | 83 | # 设置环境变量 84 | ENV PYTHONDONTWRITEBYTECODE=1 85 | ENV PYTHONUNBUFFERED=1 86 | WORKDIR /app 87 | 88 | # 安装 Python 依赖 89 | COPY requirements.txt . 90 | RUN pip install --no-cache-dir --upgrade pip && \ 91 | pip install --no-cache-dir -r requirements.txt 92 | 93 | # 复制应用代码 94 | COPY . . 95 | 96 | # 创建并切换到非 root 用户 97 | RUN useradd --create-home appuser && \ 98 | chown -R appuser:appuser /app 99 | USER appuser 100 | 101 | # 暴露端口并启动 102 | EXPOSE 8000 103 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] 104 | 105 | 106 | --- 文件路径: docker-compose.yml --- 107 | 108 | services: 109 | nginx: 110 | image: nginx:latest 111 | container_name: smithery-2api-nginx 112 | restart: always 113 | ports: 114 | - "${NGINX_PORT:-8088}:80" 115 | volumes: 116 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 117 | depends_on: 118 | - app 119 | networks: 120 | - smithery-net 121 | 122 | app: 123 | build: 124 | context: . 125 | dockerfile: Dockerfile 126 | container_name: smithery-2api-app 127 | restart: unless-stopped 128 | env_file: 129 | - .env 130 | networks: 131 | - smithery-net 132 | 133 | networks: 134 | smithery-net: 135 | driver: bridge 136 | 137 | 138 | --- 文件路径: main.py --- 139 | 140 | import logging 141 | from contextlib import asynccontextmanager 142 | from typing import Optional 143 | 144 | from fastapi import FastAPI, Request, HTTPException, Depends, Header 145 | from fastapi.responses import JSONResponse, StreamingResponse 146 | 147 | from app.core.config import settings 148 | from app.providers.smithery_provider import SmitheryProvider 149 | 150 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 151 | logger = logging.getLogger(__name__) 152 | 153 | provider = SmitheryProvider() 154 | 155 | @asynccontextmanager 156 | async def lifespan(app: FastAPI): 157 | logger.info(f"应用启动中... {settings.APP_NAME} v{settings.APP_VERSION}") 158 | logger.info("服务已进入 'Cloudscraper' 模式,将自动处理 Cloudflare 挑战。") 159 | logger.info(f"服务将在 http://localhost:{settings.NGINX_PORT} 上可用") 160 | yield 161 | logger.info("应用关闭。") 162 | 163 | app = FastAPI( 164 | title=settings.APP_NAME, 165 | version=settings.APP_VERSION, 166 | description=settings.DESCRIPTION, 167 | lifespan=lifespan 168 | ) 169 | 170 | async def verify_api_key(authorization: Optional[str] = Header(None)): 171 | if settings.API_MASTER_KEY and settings.API_MASTER_KEY != "1": 172 | if not authorization or "bearer" not in authorization.lower(): 173 | raise HTTPException(status_code=401, detail="需要 Bearer Token 认证。") 174 | token = authorization.split(" ")[-1] 175 | if token != settings.API_MASTER_KEY: 176 | raise HTTPException(status_code=403, detail="无效的 API Key。") 177 | 178 | @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) 179 | async def chat_completions(request: Request) -> StreamingResponse: 180 | try: 181 | request_data = await request.json() 182 | return await provider.chat_completion(request_data) 183 | except Exception as e: 184 | logger.error(f"处理聊天请求时发生顶层错误: {e}", exc_info=True) 185 | raise HTTPException(status_code=500, detail=f"内部服务器错误: {str(e)}") 186 | 187 | @app.get("/v1/models", dependencies=[Depends(verify_api_key)], response_class=JSONResponse) 188 | async def list_models(): 189 | return await provider.get_models() 190 | 191 | @app.get("/", summary="根路径") 192 | def root(): 193 | return {"message": f"欢迎来到 {settings.APP_NAME} v{settings.APP_VERSION}. 服务运行正常。"} 194 | 195 | 196 | --- 文件路径: nginx.conf --- 197 | 198 | worker_processes auto; 199 | 200 | events { 201 | worker_connections 1024; 202 | } 203 | 204 | http { 205 | upstream smithery_backend { 206 | server app:8000; 207 | } 208 | 209 | server { 210 | listen 80; 211 | server_name localhost; 212 | 213 | location / { 214 | proxy_pass http://smithery_backend; 215 | proxy_set_header Host $host; 216 | proxy_set_header X-Real-IP $remote_addr; 217 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 218 | proxy_set_header X-Forwarded-Proto $scheme; 219 | 220 | proxy_buffering off; 221 | proxy_cache off; 222 | proxy_set_header Connection ''; 223 | proxy_http_version 1.1; 224 | chunked_transfer_encoding off; 225 | } 226 | } 227 | } 228 | 229 | 230 | --- 文件路径: requirements.txt --- 231 | 232 | fastapi 233 | uvicorn[standard] 234 | pydantic-settings 235 | python-dotenv 236 | cloudscraper 237 | cachetools 238 | httpx 239 | 240 | 241 | --- 文件路径: app\core\__init__.py --- 242 | 243 | 244 | 245 | --- 文件路径: app\core\config.py --- 246 | 247 | import os 248 | import json 249 | import logging 250 | from pydantic_settings import BaseSettings, SettingsConfigDict 251 | from typing import List, Optional, Dict 252 | 253 | # 获取一个日志记录器实例 254 | logger = logging.getLogger(__name__) 255 | 256 | class AuthCookie: 257 | """ 258 | 处理并生成 Smithery.ai 所需的认证 Cookie。 259 | 它将 .env 文件中的 JSON 字符串转换为一个标准的 HTTP Cookie 头部字符串。 260 | """ 261 | def __init__(self, json_string: str): 262 | try: 263 | # 1. 解析从 .env 文件读取的 JSON 字符串 264 | data = json.loads(json_string) 265 | self.access_token = data.get("access_token") 266 | self.refresh_token = data.get("refresh_token") 267 | self.expires_at = data.get("expires_at", 0) 268 | 269 | if not self.access_token: 270 | raise ValueError("Cookie JSON 中缺少 'access_token'") 271 | 272 | # 2. 构造将要放入 Cookie header 的值部分 (它本身也是一个 JSON) 273 | # 注意:这里我们只包含 Supabase auth 需要的核心字段 274 | cookie_value_data = { 275 | "access_token": self.access_token, 276 | "refresh_token": self.refresh_token, 277 | "token_type": data.get("token_type", "bearer"), 278 | "expires_in": data.get("expires_in", 3600), 279 | "expires_at": self.expires_at, 280 | "user": data.get("user") 281 | } 282 | 283 | # 3. 构造完整的 Cookie 键值对字符串 284 | # Smithery.ai 使用的 Supabase project_ref 是 'spjawbfpwezjfmicopsl' 285 | project_ref = "spjawbfpwezjfmicopsl" 286 | cookie_key = f"sb-{project_ref}-auth-token" 287 | # 将值部分转换为紧凑的 JSON 字符串 288 | cookie_value = json.dumps(cookie_value_data, separators=(',', ':')) 289 | 290 | # 最终用于 HTTP Header 的字符串,格式为 "key=value" 291 | self.header_cookie_string = f"{cookie_key}={cookie_value}" 292 | 293 | except json.JSONDecodeError as e: 294 | raise ValueError(f"无法从提供的字符串中解析认证 JSON: {e}") 295 | except Exception as e: 296 | raise ValueError(f"初始化 AuthCookie 时出错: {e}") 297 | 298 | def __repr__(self): 299 | return f"" 300 | 301 | 302 | class Settings(BaseSettings): 303 | model_config = SettingsConfigDict( 304 | env_file=".env", 305 | env_file_encoding='utf-8', 306 | extra="ignore" 307 | ) 308 | 309 | APP_NAME: str = "smithery-2api" 310 | APP_VERSION: str = "1.0.0" 311 | DESCRIPTION: str = "一个将 smithery.ai 转换为兼容 OpenAI 格式 API 的高性能代理,支持多账号、上下文和工具调用。" 312 | 313 | CHAT_API_URL: str = "https://smithery.ai/api/chat" 314 | TOKEN_REFRESH_URL: str = "https://spjawbfpwezjfmicopsl.supabase.co/auth/v1/token?grant_type=refresh_token" 315 | SUPABASE_API_KEY: str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNwamF3YmZwd2V6amZtaWNvcHNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQxNDc0MDUsImV4cCI6MjA0OTcyMzQwNX0.EBIg7_F2FZh4KZ3UNwZdBRjpp2fgHqXGJOvOSQ053MU" 316 | 317 | API_MASTER_KEY: Optional[str] = None 318 | 319 | AUTH_COOKIES: List[AuthCookie] = [] 320 | 321 | API_REQUEST_TIMEOUT: int = 180 322 | NGINX_PORT: int = 8088 323 | SESSION_CACHE_TTL: int = 3600 324 | 325 | KNOWN_MODELS: List[str] = [ 326 | "claude-haiku-4.5", "claude-sonnet-4.5", "gpt-5", "gpt-5-mini", 327 | "gpt-5-nano", "gemini-2.5-flash-lite", "gemini-2.5-pro", "glm-4.6", 328 | "grok-4-fast-non-reasoning", "grok-4-fast-reasoning", "kimi-k2", "deepseek-reasoner" 329 | ] 330 | 331 | def __init__(self, **values): 332 | super().__init__(**values) 333 | # 从环境变量 SMITHERY_COOKIE_1, SMITHERY_COOKIE_2, ... 加载 cookies 334 | i = 1 335 | while True: 336 | cookie_str = os.getenv(f"SMITHERY_COOKIE_{i}") 337 | if cookie_str: 338 | try: 339 | # 使用 AuthCookie 类来解析和处理 cookie 字符串 340 | self.AUTH_COOKIES.append(AuthCookie(cookie_str)) 341 | except ValueError as e: 342 | logger.warning(f"无法加载或解析 SMITHERY_COOKIE_{i}: {e}") 343 | i += 1 344 | else: 345 | break 346 | 347 | if not self.AUTH_COOKIES: 348 | raise ValueError("必须在 .env 文件中至少配置一个有效的 SMITHERY_COOKIE_1") 349 | 350 | settings = Settings() 351 | 352 | 353 | --- 文件路径: app\providers\__init__.py --- 354 | 355 | 356 | 357 | --- 文件路径: app\providers\base_provider.py --- 358 | 359 | from abc import ABC, abstractmethod 360 | from typing import Dict, Any 361 | from fastapi.responses import StreamingResponse, JSONResponse 362 | 363 | class BaseProvider(ABC): 364 | @abstractmethod 365 | async def chat_completion( 366 | self, 367 | request_data: Dict[str, Any] 368 | ) -> StreamingResponse: 369 | pass 370 | 371 | @abstractmethod 372 | async def get_models(self) -> JSONResponse: 373 | pass 374 | 375 | 376 | --- 文件路径: app\providers\smithery_provider.py --- 377 | 378 | import json 379 | import time 380 | import logging 381 | import uuid 382 | import random 383 | import cloudscraper 384 | from typing import Dict, Any, AsyncGenerator, List 385 | 386 | from fastapi import HTTPException 387 | from fastapi.responses import StreamingResponse, JSONResponse 388 | 389 | from app.core.config import settings 390 | from app.providers.base_provider import BaseProvider 391 | # 移除了不再使用的 SessionManager 392 | # from app.services.session_manager import SessionManager 393 | from app.utils.sse_utils import create_sse_data, create_chat_completion_chunk, DONE_CHUNK 394 | 395 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - [%(levelname)s] - %(message)s') 396 | logger = logging.getLogger(__name__) 397 | 398 | class SmitheryProvider(BaseProvider): 399 | def __init__(self): 400 | # self.session_manager = SessionManager() # 移除会话管理器 401 | self.scraper = cloudscraper.create_scraper() 402 | self.cookie_index = 0 403 | 404 | def _get_cookie(self) -> str: 405 | """从配置中轮换获取一个格式正确的 Cookie 字符串。""" 406 | auth_cookie_obj = settings.AUTH_COOKIES[self.cookie_index] 407 | self.cookie_index = (self.cookie_index + 1) % len(settings.AUTH_COOKIES) 408 | return auth_cookie_obj.header_cookie_string 409 | 410 | def _convert_messages_to_smithery_format(self, openai_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 411 | """ 412 | 将客户端发来的 OpenAI 格式消息列表转换为 Smithery.ai 后端所需的格式。 413 | 例如: {"role": "user", "content": "你好"} -> {"role": "user", "parts": [{"type": "text", "text": "你好"}]} 414 | """ 415 | smithery_messages = [] 416 | for msg in openai_messages: 417 | role = msg.get("role") 418 | content = msg.get("content", "") 419 | 420 | # 忽略格式不正确或内容为空的消息 421 | if not role or not isinstance(content, str): 422 | continue 423 | 424 | smithery_messages.append({ 425 | "role": role, 426 | "parts": [{"type": "text", "text": content}], 427 | "id": f"msg-{uuid.uuid4().hex[:16]}" 428 | }) 429 | return smithery_messages 430 | 431 | async def chat_completion(self, request_data: Dict[str, Any]) -> StreamingResponse: 432 | """ 433 | 处理聊天补全请求。 434 | 此实现为无状态模式,完全依赖客户端发送的完整对话历史。 435 | """ 436 | 437 | # 1. 直接从客户端请求中获取完整的消息历史 438 | messages_from_client = request_data.get("messages", []) 439 | 440 | # 2. 将其转换为 Smithery.ai 后端所需的格式 441 | smithery_formatted_messages = self._convert_messages_to_smithery_format(messages_from_client) 442 | 443 | async def stream_generator() -> AsyncGenerator[bytes, None]: 444 | request_id = f"chatcmpl-{uuid.uuid4()}" 445 | model = request_data.get("model", "claude-haiku-4.5") 446 | 447 | try: 448 | # 3. 使用转换后的消息列表准备请求体 449 | payload = self._prepare_payload(model, smithery_formatted_messages) 450 | headers = self._prepare_headers() 451 | 452 | logger.info("===================== [REQUEST TO SMITHERY (Stateless)] =====================") 453 | logger.info(f"URL: POST {settings.CHAT_API_URL}") 454 | logger.info(f"PAYLOAD:\n{json.dumps(payload, indent=2, ensure_ascii=False)}") 455 | logger.info("=====================================================================================") 456 | 457 | # 使用 cloudscraper 发送请求 458 | response = self.scraper.post( 459 | settings.CHAT_API_URL, 460 | headers=headers, 461 | json=payload, 462 | stream=True, 463 | timeout=settings.API_REQUEST_TIMEOUT 464 | ) 465 | 466 | if response.status_code != 200: 467 | logger.error("==================== [RESPONSE FROM SMITHERY (ERROR)] ===================") 468 | logger.error(f"STATUS CODE: {response.status_code}") 469 | logger.error(f"RESPONSE BODY:\n{response.text}") 470 | logger.error("=================================================================") 471 | 472 | response.raise_for_status() 473 | 474 | # 4. 流式处理返回的数据 (此部分逻辑不变) 475 | for line in response.iter_lines(): 476 | if line.startswith(b"data:"): 477 | content = line[len(b"data:"):].strip() 478 | if content == b"[DONE]": 479 | break 480 | try: 481 | data = json.loads(content) 482 | if data.get("type") == "text-delta": 483 | delta_content = data.get("delta", "") 484 | chunk = create_chat_completion_chunk(request_id, model, delta_content) 485 | yield create_sse_data(chunk) 486 | except json.JSONDecodeError: 487 | if content: 488 | logger.warning(f"无法解析 SSE 数据块: {content}") 489 | continue 490 | 491 | # 5. 无状态模式下,无需保存任何会话,直接发送结束标志 492 | final_chunk = create_chat_completion_chunk(request_id, model, "", "stop") 493 | yield create_sse_data(final_chunk) 494 | yield DONE_CHUNK 495 | 496 | except Exception as e: 497 | logger.error(f"处理流时发生错误: {e}", exc_info=True) 498 | error_message = f"内部服务器错误: {str(e)}" 499 | error_chunk = create_chat_completion_chunk(request_id, model, error_message, "stop") 500 | yield create_sse_data(error_chunk) 501 | yield DONE_CHUNK 502 | 503 | return StreamingResponse(stream_generator(), media_type="text/event-stream") 504 | 505 | def _prepare_headers(self) -> Dict[str, str]: 506 | # 包含我们之前分析出的所有必要请求头 507 | return { 508 | "Accept": "*/*", 509 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", 510 | "Content-Type": "application/json", 511 | "Cookie": self._get_cookie(), 512 | "Origin": "https://smithery.ai", 513 | "Referer": "https://smithery.ai/playground", 514 | "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", 515 | "priority": "u=1, i", 516 | "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 517 | "sec-ch-ua-mobile": "?0", 518 | "sec-ch-ua-platform": '"Windows"', 519 | "sec-fetch-dest": "empty", 520 | "sec-fetch-mode": "cors", 521 | "sec-fetch-site": "same-origin", 522 | "x-posthog-distinct-id": "5905f6b4-d74f-46b4-9b4f-9dbbccb29bee", 523 | "x-posthog-session-id": "0199f71f-8c42-7f9a-ba3a-ff5999dd444a", 524 | "x-posthog-window-id": "0199f71f-8c42-7f9a-ba3a-ff5ab5b20a8e", 525 | } 526 | 527 | def _prepare_payload(self, model: str, messages: List[Dict[str, Any]]) -> Dict[str, Any]: 528 | return { 529 | "messages": messages, 530 | "tools": [], 531 | "model": model, 532 | "systemPrompt": "You are a helpful assistant." 533 | } 534 | 535 | async def get_models(self) -> JSONResponse: 536 | model_data = { 537 | "object": "list", 538 | "data": [ 539 | {"id": name, "object": "model", "created": int(time.time()), "owned_by": "lzA6"} 540 | for name in settings.KNOWN_MODELS 541 | ] 542 | } 543 | return JSONResponse(content=model_data) 544 | 545 | 546 | --- 文件路径: app\services\session_manager.py --- 547 | 548 | from cachetools import TTLCache 549 | from typing import List, Dict, Any 550 | from app.core.config import settings 551 | 552 | class SessionManager: 553 | def __init__(self): 554 | self.cache = TTLCache(maxsize=1024, ttl=settings.SESSION_CACHE_TTL) 555 | 556 | def get_messages(self, session_id: str) -> List[Dict[str, Any]]: 557 | """ 558 | 从缓存中获取消息历史。 559 | 返回列表的副本以防止对缓存的意外修改。 560 | """ 561 | return self.cache.get(session_id, []).copy() 562 | 563 | def update_messages(self, session_id: str, messages: List[Dict[str, Any]]): 564 | """ 565 | 将更新后的消息历史存回缓存。 566 | """ 567 | self.cache[session_id] = messages 568 | 569 | 570 | --- 文件路径: app\services\tool_caller.py --- 571 | 572 | import json 573 | import logging 574 | import random 575 | from typing import List, Dict, Any 576 | import cloudscraper 577 | 578 | logger = logging.getLogger(__name__) 579 | 580 | class ToolCaller: 581 | def __init__(self): 582 | self.mcp_url = "https://mcp.exa.ai/mcp" 583 | self.mcp_params = { 584 | "profile": "joyous-gull-NeZ2gW", 585 | "api_key": "fe5676be-931d-42e1-b5c9-90e94dce45ae" 586 | } 587 | self.scraper = cloudscraper.create_scraper() 588 | 589 | def get_tool_definitions(self) -> List[Dict[str, Any]]: 590 | # 从情报中提取的工具定义 591 | return [ 592 | {"name":"resolve-library-id","title":"Resolve Context7 Library ID","description":"...","inputSchema":{}}, 593 | {"name":"get-library-docs","title":"Get Library Docs","description":"...","inputSchema":{}}, 594 | {"name":"web_search_exa","description":"...","inputSchema":{}}, 595 | {"name":"get_code_context_exa","description":"...","inputSchema":{}} 596 | ] 597 | 598 | async def execute_tools(self, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 599 | # 此函数现在返回一个单一的用户消息,其中包含所有工具的结果 600 | all_results_content = "Tool results:\n" 601 | 602 | for call in tool_calls: 603 | function_call = call.get("function", {}) 604 | tool_name = function_call.get("name") 605 | tool_args_str = function_call.get("arguments", "{}") 606 | tool_call_id = call.get("id") 607 | 608 | logger.info(f"执行工具调用: {tool_name} with args {tool_args_str}") 609 | 610 | try: 611 | arguments = json.loads(tool_args_str) 612 | payload = { 613 | "method": "tools/call", 614 | "params": {"name": tool_name, "arguments": arguments}, 615 | "jsonrpc": "2.0", 616 | "id": random.randint(1, 100) 617 | } 618 | 619 | response = self.scraper.post(self.mcp_url, params=self.mcp_params, json=payload) 620 | response.raise_for_status() 621 | 622 | result_content = "No content returned." 623 | for line in response.iter_lines(): 624 | if line.startswith(b"data:"): 625 | content_str = line[len(b"data:"):].strip().decode('utf-8', errors='ignore') 626 | if content_str == "[DONE]": 627 | break 628 | try: 629 | data = json.loads(content_str) 630 | if "result" in data and "content" in data["result"]: 631 | # 将结果格式化为更易读的字符串 632 | result_content = json.dumps(data["result"]["content"], ensure_ascii=False, indent=2) 633 | break 634 | except json.JSONDecodeError: 635 | logger.warning(f"MCP tool: 无法解析 SSE 数据块: {content_str}") 636 | continue 637 | 638 | all_results_content += f"\n--- Result for {tool_name} (call_id: {tool_call_id}) ---\n{result_content}\n" 639 | 640 | except Exception as e: 641 | logger.error(f"工具调用失败: {e}", exc_info=True) 642 | error_str = f'{{"error": "Tool call failed", "details": "{str(e)}"}}' 643 | all_results_content += f"\n--- Error for {tool_name} (call_id: {tool_call_id}) ---\n{error_str}\n" 644 | 645 | # 返回一个单一的用户消息,而不是多个 tool 角色的消息 646 | return [{"role": "user", "content": all_results_content}] 647 | 648 | 649 | --- 文件路径: app\utils\sse_utils.py --- 650 | 651 | import json 652 | import time 653 | from typing import Dict, Any, Optional 654 | 655 | DONE_CHUNK = b"data: [DONE]\n\n" 656 | 657 | def create_sse_data(data: Dict[str, Any]) -> bytes: 658 | return f"data: {json.dumps(data)}\n\n".encode('utf-8') 659 | 660 | def create_chat_completion_chunk( 661 | request_id: str, 662 | model: str, 663 | content: str, 664 | finish_reason: Optional[str] = None 665 | ) -> Dict[str, Any]: 666 | return { 667 | "id": request_id, 668 | "object": "chat.completion.chunk", 669 | "created": int(time.time()), 670 | "model": model, 671 | "choices": [ 672 | { 673 | "index": 0, 674 | "delta": {"content": content}, 675 | "finish_reason": finish_reason 676 | } 677 | ] 678 | } 679 | 680 | 681 | 682 | --------------------------------------------------------------------------------