├── app ├── core │ ├── __init__.py │ └── config.py └── providers │ ├── __init__.py │ ├── base.py │ └── text_provider.py ├── requirements.txt ├── Dockerfile ├── docker-compose.yml ├── nginx.conf ├── main.py ├── README.md └── LICENSE /app/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/providers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn[standard] 3 | httpx 4 | pydantic-settings 5 | python-dotenv 6 | -------------------------------------------------------------------------------- /app/providers/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Dict, Any, AsyncGenerator, Union 3 | from fastapi import Request 4 | 5 | class BaseProvider(ABC): 6 | """所有 Provider 的抽象基类""" 7 | 8 | @abstractmethod 9 | async def chat_completion( 10 | self, 11 | request_data: Dict[str, Any], 12 | original_request: Request 13 | ) -> Union[Dict[str, Any], AsyncGenerator[str, None]]: 14 | """处理聊天补全请求的核心方法""" 15 | pass 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用官方的 Python 3.10 slim 版本作为基础环境 2 | FROM python:3.10-slim 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # !!!终极修正:使用 COPY . /app 来正确复制整个项目结构!!! 8 | # 这会把你的本地的 app 文件夹、requirements.txt 等,全部复制到容器的 /app 目录下 9 | COPY . /app 10 | 11 | # 设置环境变量,告诉 Python 模块的搜索路径 12 | ENV PYTHONPATH=/app 13 | 14 | # 安装所有 Python 依赖,使用国内源加速 15 | RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt 16 | 17 | # 容器启动时要执行的命令 18 | # 这个命令现在非常标准和稳定 19 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"] 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # qwen-local/docker-compose.yml (终极架构修正版) 2 | 3 | services: 4 | # 这是我们的“前台总机”服务 5 | nginx: 6 | image: nginx:latest 7 | ports: 8 | - "8082:80" 9 | volumes: 10 | # 关键修正: 直接覆盖主配置文件,而不是作为子配置被包含 11 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 12 | depends_on: 13 | - qwen-local 14 | networks: 15 | - shared_network 16 | 17 | # 这是我们的“工人”服务 18 | qwen-local: 19 | build: 20 | context: . 21 | dockerfile: Dockerfile 22 | restart: unless-stopped 23 | # 工人不直接对外暴露端口 24 | env_file: 25 | - .env 26 | environment: 27 | - API_MASTER_KEY=1 28 | - HTTP_PROXY=http://host.docker.internal:7890 29 | - HTTPS_PROXY=http://host.docker.internal:7890 30 | - NO_PROXY=localhost,127.0.0.1 31 | extra_hosts: 32 | - "host.docker.internal:host-gateway" 33 | networks: 34 | - shared_network 35 | 36 | networks: 37 | shared_network: 38 | external: true 39 | -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | # app/core/config.py (v7.2 最终修正版) 2 | 3 | from pydantic_settings import BaseSettings 4 | # !!!确保这一行包含了 Optional !!! 5 | from typing import Dict, List, Optional 6 | 7 | class Settings(BaseSettings): 8 | """ 9 | 应用配置 (v7.2 最终修正版) 10 | - 修正了因缺少 Optional 导入导致的启动崩溃问题。 11 | """ 12 | # --- 服务监听端口 --- 13 | LISTEN_PORT: int = 8082 14 | 15 | # --- 应用元数据 --- 16 | APP_NAME: str = "Qwen Multi-Account Local API" 17 | APP_VERSION: str = "7.2.0" 18 | DESCRIPTION: str = "一个支持根据模型名称动态切换账号并具备密钥认证功能的高性能通义千问本地代理。" 19 | 20 | # --- 认证与安全 --- 21 | API_MASTER_KEY: Optional[str] = None 22 | 23 | # --- 模型与账号的映射关系 --- 24 | MODEL_TO_ACCOUNT_MAP: Dict[str, int] = { 25 | "Qwen3-Max-Preview": 2 26 | } 27 | 28 | # --- 全面更新的模型列表 (仅供参考,实际以你的账号支持为准) --- 29 | SUPPORTED_MODELS: List[str] = [ 30 | "qwen-plus", "qwen-turbo", "qwen-max", "qwen-long", "qwen-vl-plus", 31 | "Qwen3-Max-Preview", 32 | ] 33 | 34 | # --- 国内站账号 1 (默认) --- 35 | CN_ACCOUNT_1_COOKIE: str = "" 36 | CN_ACCOUNT_1_XSRF_TOKEN: str = "" 37 | 38 | # --- 国内站账号 2 (专属) --- 39 | CN_ACCOUNT_2_COOKIE: str = "" 40 | CN_ACCOUNT_2_XSRF_TOKEN: str = "" 41 | 42 | # --- 国际站账号 (可选) --- 43 | INTL_COOKIE: str = "" 44 | INTL_AUTHORIZATION: str = "" 45 | INTL_BX_UA: str = "" 46 | 47 | class Config: 48 | env_file = ".env" 49 | env_file_encoding = 'utf-8' 50 | 51 | settings = Settings() 52 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | # ================================================================= 2 | # Qwen Local API - 终极粘性会话与性能版 Nginx 配置 3 | # 核心: 绝对信任后端,零干预,极致吞吐,并采用最健壮的会话保持策略 4 | # ================================================================= 5 | 6 | # --- 全局性能设置 --- 7 | worker_processes auto; 8 | worker_rlimit_nofile 102400; 9 | 10 | # --- 事件模型优化 --- 11 | events { 12 | worker_connections 102400; 13 | use epoll; 14 | multi_accept on; 15 | } 16 | 17 | # --- HTTP 核心配置 --- 18 | http { 19 | # --- 基础性能优化 --- 20 | sendfile on; 21 | tcp_nopush on; 22 | tcp_nodelay on; 23 | keepalive_timeout 15s; 24 | client_body_timeout 10s; 25 | client_header_timeout 10s; 26 | server_tokens off; 27 | access_log off; 28 | 29 | 30 | # --- 上游服务器组 (我们的 AI 工人) --- 31 | upstream qwen_backend { 32 | # 关键修正 🚀: 使用更健壮的 hash 方法实现“终极粘性会话” 33 | # 我们不再依赖可能不稳定的客户端 IP,而是使用 Authorization 请求头进行哈希。 34 | # 因为来自同一个客户端的所有请求都包含相同的 API Key,这就像“人脸识别”, 35 | # 确保了100%的会话保持,从根本上杜绝流式输出的混乱问题。 36 | # `consistent` 关键字确保在工人数量变化时,尽可能少地重新映射会话。 37 | hash $http_authorization consistent; 38 | 39 | # 性能策略: 开启与工人的“VIP连接池”,实现极致连接复用 40 | keepalive 128; 41 | 42 | # 信任策略: 移除所有健康检查和熔断机制 43 | server qwen-local:8082; 44 | } 45 | 46 | 47 | # --- 主服务器配置 (API 网关) --- 48 | server { 49 | listen 80; 50 | 51 | location / { 52 | # 性能策略: 移除所有请求限流 53 | proxy_pass http://qwen_backend; 54 | 55 | # --- 流式传输终极优化 --- 56 | proxy_buffering off; 57 | proxy_cache off; 58 | 59 | # --- 协议与头信息设置 --- 60 | proxy_http_version 1.1; 61 | proxy_set_header Connection ""; 62 | proxy_set_header Host $host; 63 | proxy_set_header X-Real-IP $remote_addr; 64 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 65 | proxy_set_header X-Forwarded-Proto $scheme; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # main.py (v7.2 终极版 - 包含模型列表接口) 2 | 3 | import traceback 4 | import time 5 | from typing import Optional, List, Dict, Any 6 | 7 | from fastapi import FastAPI, Request, HTTPException, Depends, Header 8 | 9 | from app.core.config import settings 10 | from app.providers.text_provider import TextProvider 11 | 12 | app = FastAPI( 13 | title=settings.APP_NAME, 14 | version=settings.APP_VERSION, 15 | description=settings.DESCRIPTION 16 | ) 17 | 18 | # 只需一个全能的 Provider 19 | text_provider = TextProvider() 20 | 21 | 22 | # --- 认证依赖项 (保持不变) --- 23 | async def verify_api_key(authorization: Optional[str] = Header(None)): 24 | """ 25 | 检查 API 密钥的依赖项。 26 | 如果设置了 API_MASTER_KEY,则请求头中必须包含正确的密钥。 27 | """ 28 | # 如果 .env 或 docker-compose.yml 中没有配置 API_MASTER_KEY,则跳过认证 29 | if not settings.API_MASTER_KEY: 30 | print("警告:未配置 API_MASTER_KEY,服务将对所有请求开放。") 31 | return 32 | 33 | # 如果配置了密钥,但请求头中没有 Authorization,则拒绝 34 | if authorization is None: 35 | raise HTTPException( 36 | status_code=401, 37 | detail="Unauthorized: Missing Authorization header.", 38 | ) 39 | 40 | # 检查认证方案和密钥是否正确 41 | try: 42 | scheme, token = authorization.split() 43 | if scheme.lower() != "bearer": 44 | raise ValueError("Invalid scheme") 45 | except ValueError: 46 | raise HTTPException( 47 | status_code=401, 48 | detail="Invalid authentication scheme. Use 'Bearer '.", 49 | ) 50 | 51 | # 核心验证:将传入的 token 与我们的主密钥进行安全比较 52 | if token != settings.API_MASTER_KEY: 53 | raise HTTPException( 54 | status_code=403, 55 | detail="Forbidden: Invalid API Key.", 56 | ) 57 | # 认证通过,请求将继续被处理 58 | 59 | 60 | # --- 核心聊天接口 (保持不变) --- 61 | @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) 62 | async def chat_completions(request: Request): 63 | """ 64 | 终极路由:所有请求都交给全能的 TextProvider 处理。 65 | 在处理前会先通过 verify_api_key 进行认证。 66 | """ 67 | try: 68 | request_data = await request.json() 69 | print("接收到聊天请求,认证通过,路由到全能的 TextProvider...") 70 | return await text_provider.chat_completion(request_data, request) 71 | except Exception as e: 72 | traceback.print_exc() 73 | raise HTTPException(status_code=500, detail=f"主路由发生内部服务器错误: {str(e)}") 74 | 75 | 76 | # --- 【⭐ 新增功能 ⭐】模型列表接口 --- 77 | @app.get("/v1/models", dependencies=[Depends(verify_api_key)]) 78 | async def list_models(): 79 | """ 80 | 新增的接口,用于返回兼容OpenAI格式的模型列表。 81 | 它会读取 config.py 中的 SUPPORTED_MODELS 列表。 82 | """ 83 | print("接收到模型列表请求,认证通过...") 84 | 85 | # 从我们的配置文件中获取模型列表 86 | model_names: List[str] = settings.SUPPORTED_MODELS 87 | 88 | # 将模型列表包装成OpenAI API兼容的格式 89 | model_data: List[Dict[str, Any]] = [] 90 | for name in model_names: 91 | model_data.append({ 92 | "id": name, 93 | "object": "model", 94 | "created": int(time.time()), 95 | "owned_by": "system" 96 | }) 97 | 98 | return { 99 | "object": "list", 100 | "data": model_data 101 | } 102 | 103 | 104 | # --- 根路由 (保持不变) --- 105 | @app.get("/") 106 | def root(): 107 | """根路由,提供服务基本信息,无需认证。""" 108 | return {"message": f"Welcome to {settings.APP_NAME}", "version": settings.APP_VERSION} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Qwen-2api (通义千问高性能API代理) 🚀 2 | 3 |

4 | License 5 | Python Version 6 | Framework 7 | Proxy 8 | Deployment 9 |

10 | 11 | 这是一个企业级的、高性能的通义千问网页版API代理服务。它不仅将API封装成与OpenAI格式兼容的标准接口,还通过**Nginx粘性会话**、**多账号轮询**和**模型策略路由**等高级功能,提供了极致的稳定性和灵活性。 12 | 13 | ## ✨ 核心特性 14 | 15 | - **🚀 企业级架构**: 采用 **Nginx + FastAPI** 的生产级架构,Nginx负责负载均衡与会话保持,FastAPI负责核心AI逻辑,性能卓越。 16 | - **🎯 终极粘性会话**: 利用 Nginx 对 `Authorization` 头进行哈希,100%确保同一用户的连续请求命中同一后台实例,从根本上解决流式对话内容重复与错乱问题。 17 | - **🔑 多账号支持**: 可配置多个国内站账号,并通过策略路由将特定模型的请求(如`Qwen3-Max-Preview`)定向到专属账号。 18 | - **🌍 全功能覆盖**: 支持文本、视觉、以及国际站的图像和视频生成模型。 19 | - **🔒 令牌认证**: 内置 `API_MASTER_KEY` 认证,保护您的API服务不被滥用。 20 | - **🐳 一键部署**: 提供标准的 Docker Compose 配置,无论是本地还是服务器,都能轻松启动。 21 | 22 | --- 23 | 24 | ## 🏗️ 架构原理解析 25 | 26 | 本项目采用双容器微服务架构,以实现性能与稳定性的最大化。 27 | 28 | ``` 29 | [用户/客户端] ---> [🌐 Nginx (总机)] ---> [🤖 Python FastAPI (工人)] ---> [☁️ 通义千问官网] 30 | ``` 31 | 32 | 1. **Nginx (总机)**: 作为API网关,负责处理所有外部流量、实现基于`API_KEY`的粘性会话,并将请求安全地转发给后台的Python服务。 33 | 2. **Python FastAPI (工人)**: 负责处理核心业务逻辑,包括:验证API密钥、根据模型选择合适的通义千问账号、以及将官网的累积式数据流实时转换为标准的增量流。 34 | 35 | --- 36 | 37 | ## 🚀 快速开始 (本地部署) 38 | 39 | 通过 Docker,只需几步即可在您的电脑上拥有一个私有的、高性能的API服务。 40 | 41 | ### 前提条件 42 | 43 | - 已安装 [**Docker**](https://www.docker.com/products/docker-desktop/) 和 **Docker Compose**。 44 | - 已安装 [**Git**](https://git-scm.com/)。 45 | 46 | ### 第 1 步:获取项目代码 47 | 48 | 打开您的命令行(终端),克隆本项目到您的电脑上。 49 | 50 | ```bash 51 | git clone https://github.com/lzA6/Qwen-2api.git 52 | cd Qwen-2api 53 | ``` 54 | 55 | ### 第 2 步:获取核心认证信息 56 | 57 | 本项目通过模拟网页版请求实现,因此需要您提供一次性的个人认证信息。 58 | 59 | 1. 使用您的浏览器登录 **[通义千问官网](https://tongyi.aliyun.com/chat)**。 60 | 2. 按 `F12` 打开开发者工具,并切换到 **网络 (Network)** 面板。 61 | 3. 随便发送一条消息,在网络请求列表中找到一个名为 `completions` 的请求。 62 | 4. 点击该请求,在右侧的 **标头 (Headers)** 选项卡中,向下滚动到 **请求标头 (Request Headers)** 部分。 63 | 5. **仔细、完整地**找到并复制以下两项的值: 64 | - `cookie` 65 | - `x-xsrf-token` 66 | 67 | > **💡 提示**: 如果您需要使用多账号或国际站功能,请用同样的方法获取并保存对应账号的认证信息。 68 | 69 | ### 第 3 步:配置您的项目 70 | 71 | 这是最关键的一步,您需要将您的私人信息配置到项目中。 72 | 73 | 1. 在 `Qwen-2api` 文件夹中,找到名为 `.env.example` 的文件。 74 | 2. **将它复制并重命名为 `.env`**。 75 | 3. 用文本编辑器打开这个新的 `.env` 文件,将您在**第 2 步**中获取到的信息,以及您自定义的API密钥,填写到对应的位置。 76 | 77 | **一个最简化的 `.env` 配置示例:** 78 | ```env 79 | # .env (最简配置示例) 80 | 81 | # 服务监听端口 (Nginx将监听此端口) 82 | LISTEN_PORT=8082 83 | 84 | # 设置一个复杂的主密钥,用于保护你的API 85 | API_MASTER_KEY=your_super_secret_master_key_123 86 | 87 | # 留空即可,表示所有模型都走账号1 88 | MODEL_TO_ACCOUNT_MAP='{}' 89 | 90 | # --- 国内站账号 1 (默认账号) --- 91 | CN_ACCOUNT_1_COOKIE="在这里粘贴你的Cookie" 92 | CN_ACCOUNT_1_XSRF_TOKEN="在这里粘贴你的XSRF-Token" 93 | 94 | # --- 国内站账号 2 (如果你只有一个账号,请将这里也设置成和账号1一样) --- 95 | CN_ACCOUNT_2_COOKIE="在这里粘贴你的Cookie" 96 | CN_ACCOUNT_2_XSRF_TOKEN="在这里粘贴你的XSRF-Token" 97 | 98 | # ... 其他国际站配置 ... 99 | ``` 100 | 101 | ### 第 4 步:创建共享网络 (仅首次需要) 102 | 103 | 由于本项目采用多容器架构,需要创建一个共享网络让它们互相通信。 104 | 105 | ```bash 106 | docker network create shared_network 107 | ``` 108 | 109 | ### 第 5 步:启动服务! 110 | 111 | 回到您的命令行(确保当前仍在 `Qwen-2api` 文件夹内),运行以下命令: 112 | 113 | ```bash 114 | docker compose up -d --build 115 | ``` 116 | Docker 将会自动构建镜像并在后台启动Nginx和Python服务。 117 | 118 | ### 第 6 步:测试您的本地API 119 | 120 | 打开另一个命令行窗口,使用 `curl` 命令来测试: 121 | 122 | ```bash 123 | curl http://localhost:8082/v1/chat/completions \ 124 | -H "Content-Type: application/json" \ 125 | -H "Authorization: Bearer your_super_secret_master_key_123" \ 126 | -d '{ 127 | "model": "qwen-plus", 128 | "messages": [{"role": "user", "content": "你好,请介绍一下你自己!"}] 129 | }' 130 | ``` 131 | **注意:** 请将 `your_super_secret_master_key_123` 替换为您在 `.env` 文件中设置的 `API_MASTER_KEY`。 132 | 133 | 如果成功返回了通义千问的回答,恭喜您,本地部署成功! 134 | 135 | --- 136 | 137 | ## ☁️ 部署到 Hugging Face (实验性) 138 | 139 | Hugging Face Spaces 的免费实例目前**不支持** Docker Compose 多容器部署。因此,如需部署到HF,需要采用**简化的单容器架构**。 140 | 141 | > **注意**: 这意味着您将失去Nginx带来的粘性会话和性能优势,但这对于轻量级使用或公开演示仍然是一个很好的选择。 142 | 143 | 详细步骤请参考[此文档](https://huggingface.co/docs/spaces/containers)进行单容器Dockerfile部署,并确保在`README.md`头部添加`sdk: docker`和`app_port: 8082`的元数据。 144 | 145 | --- 146 | 147 | ## 📖 API 参考 148 | 149 | ### 获取模型列表 150 | 151 | - **Endpoint**: `GET /v1/models` 152 | - **认证**: `Bearer ` 153 | - **响应**: 返回一个兼容OpenAI格式的可用模型列表。 154 | 155 | ### 创建聊天补全 156 | 157 | - **Endpoint**: `POST /v1/chat/completions` 158 | - **认证**: `Bearer ` 159 | - **请求体**: 160 | ```json 161 | { 162 | "model": "qwen-plus", 163 | "messages": [{"role": "user", "content": "你好!"}], 164 | "stream": true 165 | } 166 | ``` 167 | - **响应**: 返回一个标准的SSE(Server-Sent Events)流或一个JSON对象。 168 | 169 | --- 170 | 171 | ## ❓ 常见问题 (FAQ) 172 | 173 | - **Q: 为什么需要 `docker network create`?** 174 | A: 因为本项目包含 `nginx` 和 `qwen-local` 两个服务,它们需要在同一个虚拟网络中才能互相通信。这是一个标准的Docker微服务实践。 175 | 176 | - **Q: 部署后访问API提示 `403 Forbidden`?** 177 | A: 这是因为您的 `API_MASTER_KEY` 配置正确,但您的请求头 `Authorization: Bearer ...` 中的密钥不正确或缺失。请检查您的密钥。 178 | 179 | - **Q: 我的认证信息会过期吗?** 180 | A: 会的。网页版的认证信息通常有较长的有效期(数周甚至数月),但不是永久的。如果服务开始报错,您可能需要重复**第 2 步**来获取新的认证信息并更新您的 `.env` 文件,然后重启服务 (`docker compose up -d --build`)。 181 | 182 | ## 📜 License 183 | 184 | 本项目采用 [Apache License 2.0](LICENSE) 开源。 185 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/providers/text_provider.py: -------------------------------------------------------------------------------- 1 | # app/providers/text_provider.py (v11.0 终极版 - 健壮的增量转换) 2 | 3 | import httpx 4 | import json 5 | import uuid 6 | import time 7 | import traceback 8 | import asyncio 9 | from typing import Dict, Any, AsyncGenerator, Union, List 10 | 11 | from fastapi import Request 12 | from fastapi.responses import StreamingResponse, JSONResponse 13 | 14 | from app.providers.base import BaseProvider 15 | from app.core.config import settings 16 | 17 | import logging 18 | logger = logging.getLogger(__name__) 19 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 20 | 21 | 22 | class TextProvider(BaseProvider): 23 | """ 24 | 通义千问全能提供商 (v11.0 终极版) 25 | - 采用最终修正的状态化解析器,逻辑清晰,确保将官网的“全量累积流”正确转换为客户端所需的“增量流”。 26 | - 彻底解决所有场景下的内容重复问题。 27 | - 保留了会话预热和国际站功能。 28 | """ 29 | 30 | # -------------------------------------------------------------------------- 31 | # 核心入口 32 | # -------------------------------------------------------------------------- 33 | async def chat_completion(self, request_data: Dict[str, Any], original_request: Request) -> Union[StreamingResponse, JSONResponse]: 34 | model_name = request_data.get("model", "qwen-plus") 35 | task_type = self._get_task_type(model_name, request_data) 36 | try: 37 | if task_type in ["image", "video"]: 38 | logger.info(f"检测到 '{task_type}' 任务,强制使用国际站(INTL)模式...") 39 | return await self._handle_long_polling_task(request_data) 40 | else: 41 | account_id = settings.MODEL_TO_ACCOUNT_MAP.get(model_name, 1) 42 | logger.info(f"检测到模型 '{model_name}',任务类型 '{task_type}',将使用国内站账号 {account_id}...") 43 | return await self._handle_stream_task(request_data, account_id) 44 | except Exception as e: 45 | logger.error(f"处理任务时出错: {type(e).__name__}: {e}") 46 | traceback.print_exc() 47 | return JSONResponse(content={"error": {"message": f"处理任务时出错: {e}", "type": "provider_error"}}, status_code=500) 48 | 49 | # -------------------------------------------------------------------------- 50 | # 国内站流式任务处理 51 | # -------------------------------------------------------------------------- 52 | async def _handle_stream_task(self, request_data: Dict[str, Any], account_id: int) -> StreamingResponse: 53 | headers = self._prepare_cn_headers(account_id) 54 | await self._prewarm_session(headers) 55 | payload = self._prepare_cn_payload(request_data) 56 | model_name_for_client = request_data.get("model", "qwen-plus") 57 | url = "https://api.tongyi.com/dialog/conversation" 58 | logger.info(f" [CN-Account-{account_id}] 正在向模型 '{model_name_for_client}' 发送流式请求...") 59 | return StreamingResponse(self._stream_generator(url, headers, payload, model_name_for_client), media_type="text/event-stream") 60 | 61 | async def _prewarm_session(self, headers: Dict[str, Any]): 62 | try: 63 | logger.info(" [Pre-warm] 正在发送会话预热请求...") 64 | url = "https://api.tongyi.com/assistant/api/record/list" 65 | payload = { 66 | "pageNo": 1, "terminal": "web", "pageSize": 10000, "module": "uploadhistory", 67 | "fileTypes": ["file", "audio", "video"], "recordSources": ["chat", "zhiwen", "tingwu"], 68 | "status": [20, 30, 40, 41], "taskTypes": ["local", "net_source", "doc_read", "paper_read", "book_read"] 69 | } 70 | async with httpx.AsyncClient() as client: 71 | prewarm_headers = headers.copy() 72 | prewarm_headers['Accept'] = 'application/json, text/plain, */*' 73 | response = await client.post(url, headers=prewarm_headers, json=payload, timeout=10) 74 | response.raise_for_status() 75 | logger.info(" [Pre-warm] ✅ 会话预热成功!") 76 | except Exception as e: 77 | logger.warning(f" [Pre-warm] ⚠️ 会话预热失败: {e}。继续尝试...") 78 | 79 | # -------------------------------------------------------------------------- 80 | # 终极版高级流式解析器 (v11.0) - 核心修改 81 | # -------------------------------------------------------------------------- 82 | async def _stream_generator(self, url: str, headers: Dict, payload: Dict, model_name: str) -> AsyncGenerator[str, None]: 83 | """ 84 | 健壮的状态化流式生成器,将通义千问的“全量累积流”转换为标准的“增量流”。 85 | """ 86 | chat_id = f"chatcmpl-{uuid.uuid4().hex}" 87 | is_first_chunk = True 88 | full_content_so_far = "" # 关键(1) 🧠: 状态变量在生成器函数的顶层作用域,确保在循环中持久存在 89 | 90 | try: 91 | async with httpx.AsyncClient(timeout=60) as client: 92 | async with client.stream("POST", url, headers=headers, json=payload) as response: 93 | response.raise_for_status() 94 | 95 | async for line in response.aiter_lines(): 96 | if not line.startswith('data:'): 97 | continue 98 | 99 | raw_data_str = line.strip()[len('data:'):] 100 | if not raw_data_str or "[DONE]" in raw_data_str: 101 | continue 102 | 103 | try: 104 | qwen_data = json.loads(raw_data_str) 105 | 106 | # 从所有内容块中筛选出 'text' 类型的内容块 107 | text_blocks = [block for block in qwen_data.get("contents", []) if block.get("contentType") == "text"] 108 | if not text_blocks: 109 | continue 110 | 111 | # 通常我们只关心最后一个text块,因为它包含了最新的完整内容 112 | latest_text_block = text_blocks[-1] 113 | new_full_content = latest_text_block.get("content", "") 114 | 115 | if new_full_content is None: 116 | continue 117 | 118 | # 关键(2) 💡: 基于持久化的状态,精确计算增量 119 | delta_content = "" 120 | if new_full_content.startswith(full_content_so_far): 121 | delta_content = new_full_content[len(full_content_so_far):] 122 | else: 123 | logger.warning(f" [Stream Reset] 流内容不连续,将发送全部新内容。") 124 | delta_content = new_full_content 125 | 126 | # 如果没有实际的新内容,就跳过 127 | if not delta_content: 128 | continue 129 | 130 | # 关键(3) ✅: 发送增量前,先发送角色信息(仅一次) 131 | if is_first_chunk: 132 | role_chunk = { 133 | "id": chat_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, 134 | "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}] 135 | } 136 | yield f"data: {json.dumps(role_chunk, ensure_ascii=False)}\n\n" 137 | is_first_chunk = False 138 | 139 | # 发送真正的增量内容块 140 | openai_chunk = { 141 | "id": chat_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, 142 | "choices": [{"index": 0, "delta": {"content": delta_content}, "finish_reason": None}] 143 | } 144 | yield f"data: {json.dumps(openai_chunk, ensure_ascii=False)}\n\n" 145 | 146 | # 关键(4) 🔄: 更新状态,为处理下一个 data: 消息做准备 147 | full_content_so_far = new_full_content 148 | 149 | except json.JSONDecodeError: 150 | logger.warning(f" [Warning] JSON 解析失败: {raw_data_str}") 151 | continue 152 | 153 | # 流结束,发送终止块 154 | final_chunk = { 155 | "id": chat_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": model_name, 156 | "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}] 157 | } 158 | yield f"data: {json.dumps(final_chunk, ensure_ascii=False)}\n\n" 159 | 160 | except Exception as e: 161 | logger.error(f" [Error] 流式生成器发生错误: {e}") 162 | traceback.print_exc() 163 | 164 | finally: 165 | logger.info(" [Stream] 流式传输结束。") 166 | yield "data: [DONE]\n\n" 167 | 168 | # -------------------------------------------------------------------------- 169 | # 辅助函数 (保持不变) 170 | # -------------------------------------------------------------------------- 171 | def _get_task_type(self, model_name: str, request_data: Dict[str, Any]) -> str: 172 | model_name_lower = model_name.lower() 173 | if "wanx" in model_name_lower: return "image" 174 | if "animate" in model_name_lower: return "video" 175 | if "vl" in model_name_lower or "qvq" in model_name_lower: return "vision" 176 | return "text" 177 | 178 | def _prepare_cn_headers(self, account_id: int) -> Dict[str, str]: 179 | try: 180 | cookie = getattr(settings, f"CN_ACCOUNT_{account_id}_COOKIE") 181 | xsrf_token = getattr(settings, f"CN_ACCOUNT_{account_id}_XSRF_TOKEN") 182 | except AttributeError: raise ValueError(f"国内站账号 {account_id} 的配置不完整。") 183 | if not cookie or not xsrf_token: raise ValueError(f"国内站账号 {account_id} 的认证信息为空。") 184 | safe_cookie = cookie.encode('utf-8').decode('latin-1') 185 | return {'Origin': 'https://www.tongyi.com', 'Referer': 'https://www.tongyi.com/', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', 'Cookie': safe_cookie, 'x-xsrf-token': xsrf_token, 'x-platform': 'pc_tongyi', 'Accept': 'text/event-stream', 'Content-Type': 'application/json;charset=UTF-8'} 186 | 187 | def _prepare_cn_payload(self, request_data: Dict[str, Any]) -> Dict[str, Any]: 188 | messages: List[Dict[str, Any]] = request_data.get("messages", []) 189 | if not messages: messages = [{"role": "user", "content": request_data.get("prompt", "你好")}] 190 | qwen_contents = [] 191 | for msg in messages: 192 | content = msg.get("content") 193 | if isinstance(content, str): qwen_contents.append({"role": msg.get("role"), "content": content, "contentType": "text"}) 194 | model_in_payload = request_data.get("model", "") 195 | return {"action": "next", "contents": qwen_contents, "model": model_in_payload, "parentMsgId": "", "requestId": str(uuid.uuid4()), "sessionId": "", "sessionType": "text_chat", "userAction": "new_top", "feature_config": {"search_enabled": False, "thinking_enabled": False}} 196 | 197 | # -------------------------------------------------------------------------- 198 | # 国际站相关函数 (保留) 199 | # -------------------------------------------------------------------------- 200 | def _prepare_intl_headers(self) -> Dict[str, str]: 201 | if not settings.INTL_AUTHORIZATION or not settings.INTL_COOKIE or not settings.INTL_BX_UA: 202 | raise ValueError("国际站(intl)认证信息不完整,请检查.env文件。") 203 | safe_cookie = settings.INTL_COOKIE.encode('utf-8').decode('latin-1') 204 | return {'Origin': 'https://chat.qwen.ai', 'Referer': 'https://chat.qwen.ai/', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', 'Authorization': settings.INTL_AUTHORIZATION, 'Cookie': safe_cookie, 'bx-ua': settings.INTL_BX_UA} 205 | 206 | async def _handle_long_polling_task(self, request_data: Dict[str, Any]) -> JSONResponse: 207 | headers = self._prepare_intl_headers() 208 | headers['Accept'] = 'application/json, text/event-stream' 209 | headers['Content-Type'] = 'application/json;charset=UTF-8' 210 | completions_url = "https://chat.qwen.ai/api/v2/chat/completions" 211 | task_status_url_template = "https://chat.qwen.ai/api/v1/tasks/status/{task_id}" 212 | prompt = request_data.get("prompt", "一只猫") 213 | model_name = "wanx-v1" if "wanx" in request_data.get("model", "") else "animate-v1" 214 | msg_type = "t2i" if model_name == "wanx-v1" else "t2v" 215 | payload = {"action": "next", "contents": [{"content": prompt, "contentType": "text", "role": "user"}], "msg_type": msg_type, "mode": "chat", "model": model_name, "parentMsgId": "", "requestId": str(uuid.uuid4())} 216 | async with httpx.AsyncClient(timeout=60) as client: 217 | logger.info(f" [INTL] 正在启动 '{model_name}' 任务...") 218 | response = await client.post(completions_url, headers=headers, json=payload) 219 | response.raise_for_status() 220 | task_id = None 221 | async for line in response.aiter_lines(): 222 | if line.startswith('data:'): 223 | try: 224 | data = json.loads(line[len('data:'):]) 225 | if data.get("taskIds"): 226 | task_id = data["taskIds"][0] 227 | break 228 | except json.JSONDecodeError: continue 229 | if not task_id: raise ValueError(f"{model_name} 任务启动失败。") 230 | logger.info(f" [INTL] 成功获取任务 ID: {task_id}") 231 | for i in range(120): 232 | await asyncio.sleep(3) 233 | status_url = task_status_url_template.format(task_id=task_id) 234 | status_response = await client.get(status_url, headers=headers) 235 | if status_response.status_code == 200: 236 | data = status_response.json() 237 | if data.get("status") == "succeeded": 238 | logger.info(f" [INTL] {model_name} 任务成功!") 239 | return self._format_media_response(data, request_data, model_name) 240 | if data.get("status") == "failed": 241 | raise RuntimeError(f"任务失败: {data.get('result', '未知错误')}") 242 | raise TimeoutError("任务超时。") 243 | 244 | def _format_media_response(self, task_result: Dict[str, Any], request_data: Dict[str, Any], task_type: str) -> JSONResponse: 245 | model_name = request_data.get("model") 246 | items = task_result.get("result", {}).get("images" if "wanx" in task_type else "videos", []) 247 | urls = [item.get("url") for item in items if item.get("url")] 248 | content = "\n".join(f"!image({url})" for url in urls) if "wanx" in task_type else "\n".join(f"视频链接: {url}" for url in urls) 249 | response_data = {"id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion", "created": int(time.time()), "model": model_name, "choices": [{"index": 0, "message": {"role": "assistant", "content": content or "生成完成,但未能获取链接。"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}} 250 | return JSONResponse(content=response_data) --------------------------------------------------------------------------------