mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 16:35:27 +08:00
Merge pull request #24 from CJackHwang/dev
chore: Standardize Go version to 1.24 across Dockerfile, go.mod, GitHub Actions, and documentation.
This commit is contained in:
108
.env.example
108
.env.example
@@ -1,74 +1,62 @@
|
||||
# DS2API 环境变量配置模板
|
||||
# 复制此文件为 .env 并根据需要修改
|
||||
# 最后更新:2026-02
|
||||
# DS2API environment template (Go runtime)
|
||||
# Copy this file to .env and adjust values.
|
||||
# Updated: 2026-02
|
||||
|
||||
# ===============================================================
|
||||
# 核心配置
|
||||
# ===============================================================
|
||||
|
||||
# ----- 服务配置 -----
|
||||
# 服务端口(默认 5001)
|
||||
# ---------------------------------------------------------------
|
||||
# Runtime
|
||||
# ---------------------------------------------------------------
|
||||
# HTTP listen port (default: 5001)
|
||||
PORT=5001
|
||||
|
||||
# 服务监听地址
|
||||
HOST=0.0.0.0
|
||||
|
||||
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||
# Log level: DEBUG | INFO | WARN | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Max concurrent inflight requests per account in managed-key mode.
|
||||
# Default: 2
|
||||
# Recommended client concurrency is calculated dynamically as:
|
||||
# account_count * DS2API_ACCOUNT_MAX_INFLIGHT
|
||||
# So by default it is account_count * 2.
|
||||
# Alias: DS2API_ACCOUNT_CONCURRENCY
|
||||
# DS2API_ACCOUNT_MAX_INFLIGHT=2
|
||||
|
||||
# ===============================================================
|
||||
# 数据配置(三选一)
|
||||
# ===============================================================
|
||||
# ---------------------------------------------------------------
|
||||
# Admin auth
|
||||
# ---------------------------------------------------------------
|
||||
# Admin key for /admin login and protected admin APIs.
|
||||
# Default is "admin" when unset, but setting it explicitly is recommended.
|
||||
DS2API_ADMIN_KEY=admin
|
||||
|
||||
# 方式1: JSON 字符串(适合简单配置)
|
||||
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
|
||||
# Optional JWT signing secret for admin token.
|
||||
# Defaults to DS2API_ADMIN_KEY when unset.
|
||||
# DS2API_JWT_SECRET=change-me
|
||||
|
||||
# 方式2: Base64 编码的 JSON(推荐用于 Vercel,避免特殊字符转义问题)
|
||||
# 生成方式: echo '{"keys":["your-api-key"],"accounts":[...]}' | base64
|
||||
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
|
||||
# Optional admin JWT validity in hours (default: 24)
|
||||
# DS2API_JWT_EXPIRE_HOURS=24
|
||||
|
||||
# 方式3: 配置文件路径(本地开发推荐)
|
||||
# ---------------------------------------------------------------
|
||||
# Config source (choose one)
|
||||
# ---------------------------------------------------------------
|
||||
# Option A: config file path (local/dev recommended)
|
||||
# DS2API_CONFIG_PATH=config.json
|
||||
|
||||
# Option B: JSON string
|
||||
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
|
||||
|
||||
# ===============================================================
|
||||
# 管理界面配置
|
||||
# ===============================================================
|
||||
# Option C: Base64 encoded JSON (recommended for Vercel env var)
|
||||
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
|
||||
|
||||
# Admin API 密钥(Vercel 部署必填!)
|
||||
# 用于保护 WebUI 管理界面,首次访问 /admin 时需要输入此密钥登录
|
||||
DS2API_ADMIN_KEY=your-admin-secret-key
|
||||
|
||||
# JWT Token 过期时间(秒,默认 86400 = 24小时)
|
||||
# DS2API_SESSION_EXPIRE=86400
|
||||
|
||||
|
||||
# ===============================================================
|
||||
# Vercel 集成(可选)
|
||||
# ===============================================================
|
||||
|
||||
# Vercel API Token
|
||||
# 获取方式: https://vercel.com/account/tokens
|
||||
# VERCEL_TOKEN=your-vercel-token
|
||||
|
||||
# Vercel Project ID
|
||||
# 获取方式: Vercel 控制台 -> 项目设置 -> General -> Project ID
|
||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
|
||||
|
||||
# Vercel Team ID(个人项目无需填写,团队项目才需要)
|
||||
# VERCEL_TEAM_ID=
|
||||
|
||||
|
||||
# ===============================================================
|
||||
# 高级配置(可选)
|
||||
# ===============================================================
|
||||
|
||||
# Tokenizer 目录(留空使用项目根目录)
|
||||
# DS2API_TOKENIZER_DIR=
|
||||
|
||||
# 模板目录
|
||||
# DS2API_TEMPLATES_DIR=templates
|
||||
|
||||
# WASM 文件路径(PoW 计算用)
|
||||
# ---------------------------------------------------------------
|
||||
# Paths (optional)
|
||||
# ---------------------------------------------------------------
|
||||
# WASM file used for PoW solving
|
||||
# DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm
|
||||
|
||||
# Built admin static assets directory
|
||||
# DS2API_STATIC_ADMIN_DIR=static/admin
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Vercel sync integration (optional)
|
||||
# ---------------------------------------------------------------
|
||||
# VERCEL_TOKEN=your-vercel-token
|
||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
|
||||
# VERCEL_TEAM_ID=team_xxxxxxxxxxxx
|
||||
|
||||
83
.github/workflows/release-artifacts.yml
vendored
Normal file
83
.github/workflows/release-artifacts.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Release Artifacts
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-upload:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.x"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: webui/package-lock.json
|
||||
|
||||
- name: Build WebUI
|
||||
run: |
|
||||
npm ci --prefix webui
|
||||
npm run build --prefix webui
|
||||
|
||||
- name: Build Multi-Platform Archives
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
mkdir -p dist
|
||||
|
||||
targets=(
|
||||
"linux/amd64"
|
||||
"linux/arm64"
|
||||
"darwin/amd64"
|
||||
"darwin/arm64"
|
||||
"windows/amd64"
|
||||
)
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
GOOS="${target%/*}"
|
||||
GOARCH="${target#*/}"
|
||||
PKG="ds2api_${TAG}_${GOOS}_${GOARCH}"
|
||||
STAGE="dist/${PKG}"
|
||||
BIN="ds2api"
|
||||
if [ "${GOOS}" = "windows" ]; then
|
||||
BIN="ds2api.exe"
|
||||
fi
|
||||
|
||||
mkdir -p "${STAGE}/static"
|
||||
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
|
||||
go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api
|
||||
|
||||
cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/"
|
||||
cp -R static/admin "${STAGE}/static/admin"
|
||||
|
||||
if [ "${GOOS}" = "windows" ]; then
|
||||
(cd dist && zip -rq "${PKG}.zip" "${PKG}")
|
||||
else
|
||||
tar -C dist -czf "dist/${PKG}.tar.gz" "${PKG}"
|
||||
fi
|
||||
|
||||
rm -rf "${STAGE}"
|
||||
done
|
||||
|
||||
(cd dist && sha256sum *.tar.gz *.zip > sha256sums.txt)
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.zip
|
||||
dist/sha256sums.txt
|
||||
@@ -2,30 +2,23 @@
|
||||
|
||||
Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
|
||||
|
||||
Thank you for contributing to DS2API!
|
||||
Thanks for contributing to DS2API.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Backend
|
||||
### Backend (Go)
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
# 1. Clone
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Create a virtual environment (recommended)
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 3. Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. Configure
|
||||
# 2. Configure
|
||||
cp config.example.json config.json
|
||||
# Edit config.json
|
||||
|
||||
# 5. Run
|
||||
python dev.py
|
||||
# 3. Run backend
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
### Frontend (WebUI)
|
||||
@@ -36,59 +29,42 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
WebUI language packs live in `webui/src/locales/`. Add new locale JSON files there.
|
||||
WebUI locales are in `webui/src/locales/`.
|
||||
|
||||
## Code Standards
|
||||
|
||||
- **Python**: Follow PEP 8, use 4-space indentation
|
||||
- **JavaScript/React**: Use 4-space indentation and function components
|
||||
- **Commit messages**: Use semantic prefixes (e.g. `feat:`, `fix:`, `docs:`)
|
||||
- **Go**: run `gofmt` and make sure `go test ./...` passes before committing
|
||||
- **JavaScript/React**: follow existing project style (functional components)
|
||||
- **Commit messages**: use semantic prefixes (`feat:`, `fix:`, `docs:`)
|
||||
|
||||
## Submitting a PR
|
||||
|
||||
1. Fork this repo
|
||||
2. Create a feature branch (`git checkout -b feature/xxx`)
|
||||
3. Commit your changes (`git commit -m 'feat: add xxx'`)
|
||||
4. Push your branch (`git push origin feature/xxx`)
|
||||
1. Fork the repo
|
||||
2. Create a branch (e.g. `feature/xxx`)
|
||||
3. Commit changes
|
||||
4. Push your branch
|
||||
5. Open a Pull Request
|
||||
|
||||
## WebUI Build
|
||||
## Build WebUI
|
||||
|
||||
> **Important**: After modifying `webui/`, **no manual build is required**.
|
||||
|
||||
When a PR is merged into `main`, GitHub Actions will automatically:
|
||||
1. Build the WebUI
|
||||
2. Commit build artifacts to `static/admin/`
|
||||
|
||||
If you need a local build (for testing):
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
ds2api/
|
||||
├── app.py # FastAPI entrypoint
|
||||
├── dev.py # Development server
|
||||
├── core/ # Core modules
|
||||
│ ├── auth.py # Account auth & rotation
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── deepseek.py # DeepSeek API calls
|
||||
│ ├── models.py # Model definitions
|
||||
│ ├── pow.py # PoW calculations
|
||||
│ └── sse_parser.py # SSE parsing
|
||||
├── routes/ # API routes
|
||||
│ ├── openai.py # OpenAI-compatible endpoints
|
||||
│ ├── claude.py # Claude-compatible endpoints
|
||||
│ ├── home.py # Landing page routes
|
||||
│ └── admin/ # Admin endpoints
|
||||
├── cmd/ds2api/ # Local/container entrypoint
|
||||
├── api/index.go # Vercel serverless entrypoint
|
||||
├── internal/ # Go backend implementation
|
||||
├── webui/ # React WebUI source
|
||||
├── static/admin/ # WebUI build output (auto-generated)
|
||||
└── scripts/ # Helper scripts
|
||||
├── static/admin/ # WebUI build output
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── vercel.json
|
||||
```
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Use [GitHub Issues](https://github.com/CJackHwang/ds2api/issues)
|
||||
- Provide detailed reproduction steps and logs
|
||||
Please use [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) with reproducible steps and logs.
|
||||
|
||||
@@ -2,33 +2,26 @@
|
||||
|
||||
语言 / Language: [中文](CONTRIBUTING.md) | [English](CONTRIBUTING.en.md)
|
||||
|
||||
感谢你对 DS2API 的贡献!
|
||||
感谢你对 DS2API 的贡献。
|
||||
|
||||
## 开发环境设置
|
||||
|
||||
### 后端
|
||||
### 后端(Go)
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 创建虚拟环境(推荐)
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 3. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. 配置
|
||||
# 2. 配置
|
||||
cp config.example.json config.json
|
||||
# 编辑 config.json
|
||||
|
||||
# 5. 启动
|
||||
python dev.py
|
||||
# 3. 启动后端
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
### 前端 (WebUI)
|
||||
### 前端(WebUI)
|
||||
|
||||
```bash
|
||||
cd webui
|
||||
@@ -36,59 +29,42 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
WebUI 语言包位于 `webui/src/locales/`,新增语言请在此处添加对应 JSON 文件。
|
||||
WebUI 语言包位于 `webui/src/locales/`。
|
||||
|
||||
## 代码规范
|
||||
|
||||
- **Python**: 遵循 PEP 8,使用 4 空格缩进
|
||||
- **JavaScript/React**: 使用 4 空格缩进,使用函数组件
|
||||
- **提交信息**: 使用语义化提交格式(如 `feat:`, `fix:`, `docs:`)
|
||||
- **Go**: 提交前运行 `gofmt`,并确保 `go test ./...` 通过
|
||||
- **JavaScript/React**: 保持现有代码风格(函数组件)
|
||||
- **提交信息**: 使用语义化前缀(`feat:`, `fix:`, `docs:`)
|
||||
|
||||
## 提交 PR
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/xxx`)
|
||||
3. 提交更改 (`git commit -m 'feat: 添加xxx功能'`)
|
||||
4. 推送分支 (`git push origin feature/xxx`)
|
||||
5. 创建 Pull Request
|
||||
1. Fork 仓库
|
||||
2. 创建分支(如 `feature/xxx`)
|
||||
3. 提交更改
|
||||
4. 推送分支
|
||||
5. 发起 Pull Request
|
||||
|
||||
## WebUI 构建
|
||||
|
||||
> **重要**: 修改 `webui/` 目录后 **无需手动构建**!
|
||||
|
||||
当 PR 合并到 `main` 分支后,GitHub Actions 会自动:
|
||||
1. 构建 WebUI
|
||||
2. 提交构建产物到 `static/admin/`
|
||||
|
||||
如果需要本地构建(测试用):
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
```text
|
||||
ds2api/
|
||||
├── app.py # FastAPI 应用入口
|
||||
├── dev.py # 开发服务器
|
||||
├── core/ # 核心模块
|
||||
│ ├── auth.py # 账号认证与轮询
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── deepseek.py # DeepSeek API 调用
|
||||
│ ├── models.py # 模型定义
|
||||
│ ├── pow.py # PoW 计算
|
||||
│ └── sse_parser.py # SSE 解析
|
||||
├── routes/ # API 路由
|
||||
│ ├── openai.py # OpenAI 兼容接口
|
||||
│ ├── claude.py # Claude 兼容接口
|
||||
│ ├── home.py # 首页路由
|
||||
│ └── admin/ # 管理接口
|
||||
├── cmd/ds2api/ # 本地/容器启动入口
|
||||
├── api/index.go # Vercel Serverless 入口
|
||||
├── internal/ # Go 后端核心实现
|
||||
├── webui/ # React WebUI 源码
|
||||
├── static/admin/ # WebUI 构建产物(自动生成)
|
||||
└── scripts/ # 辅助脚本
|
||||
├── static/admin/ # WebUI 构建产物
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── vercel.json
|
||||
```
|
||||
|
||||
## 问题反馈
|
||||
|
||||
- 使用 [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) 报告问题
|
||||
- 提供详细的复现步骤和日志信息
|
||||
请使用 [GitHub Issues](https://github.com/CJackHwang/ds2api/issues) 并附复现步骤与日志。
|
||||
|
||||
435
DEPLOY.en.md
435
DEPLOY.en.md
@@ -1,410 +1,155 @@
|
||||
# DS2API Deployment Guide
|
||||
# DS2API Deployment Guide (Go)
|
||||
|
||||
Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
|
||||
|
||||
This document covers all supported DS2API deployment methods.
|
||||
This guide is aligned with the current Go codebase.
|
||||
|
||||
---
|
||||
## Deployment Modes
|
||||
|
||||
## Table of Contents
|
||||
- Local run: `go run ./cmd/ds2api`
|
||||
- Docker: `docker-compose up -d`
|
||||
- Vercel: serverless entry at `api/index.go`
|
||||
- Linux service mode: systemd
|
||||
|
||||
- [Vercel Deployment (Recommended)](#vercel-deployment-recommended)
|
||||
- [Docker Deployment (Recommended)](#docker-deployment-recommended)
|
||||
- [Local Development](#local-development)
|
||||
- [Production Deployment](#production-deployment)
|
||||
- [FAQ](#faq)
|
||||
## 0. Prerequisites
|
||||
|
||||
---
|
||||
- Go 1.24+
|
||||
- Node.js 20+ (only if you need to build WebUI locally)
|
||||
- `config.json` or `DS2API_CONFIG_JSON`
|
||||
|
||||
## Vercel Deployment (Recommended)
|
||||
|
||||
### One-click deployment
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Click the deploy button**
|
||||
- Sign in to GitHub
|
||||
- Authorize Vercel access
|
||||
|
||||
2. **Set environment variables**
|
||||
- `DS2API_ADMIN_KEY`: Admin console password (**required**)
|
||||
|
||||
3. **Wait for deployment**
|
||||
- Vercel builds and deploys automatically
|
||||
- You will receive a deployment URL
|
||||
|
||||
4. **Configure accounts**
|
||||
- Visit `https://your-project.vercel.app/admin`
|
||||
- Log in with the admin key
|
||||
- Add DeepSeek accounts
|
||||
- Set custom API keys
|
||||
|
||||
5. **Sync configuration**
|
||||
- Click "Sync to Vercel"
|
||||
- The first sync requires a Vercel token and project ID
|
||||
- After sync, the configuration is persisted
|
||||
|
||||
### Get Vercel credentials
|
||||
|
||||
**Vercel token**:
|
||||
1. Visit https://vercel.com/account/tokens
|
||||
2. Click "Create Token"
|
||||
3. Set a name and expiration
|
||||
4. Copy the token
|
||||
|
||||
**Project ID**:
|
||||
1. Open your Vercel project
|
||||
2. Go to Settings → General
|
||||
3. Copy the "Project ID"
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- Node.js 18+ (WebUI development)
|
||||
- pip
|
||||
|
||||
### Quick start
|
||||
## 1. Local Run
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. Configure accounts
|
||||
cp config.example.json config.json
|
||||
# Edit config.json and fill in DeepSeek account info
|
||||
# edit config.json
|
||||
|
||||
# 4. Start the service
|
||||
python dev.py
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
### Config example
|
||||
Default port is `5001` (override with `PORT`).
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["my-api-key-1", "my-api-key-2"],
|
||||
"accounts": [
|
||||
{
|
||||
"email": "your-email@example.com",
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
},
|
||||
{
|
||||
"mobile": "12345678901",
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- `keys`: Custom API keys for calling the service
|
||||
- `accounts`: DeepSeek Web accounts
|
||||
- Supports `email` or `mobile` login
|
||||
- Leave `token` blank; it will be fetched automatically
|
||||
|
||||
### WebUI development
|
||||
Build WebUI if `/admin` reports missing assets:
|
||||
|
||||
```bash
|
||||
# Enter the WebUI directory
|
||||
cd webui
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The WebUI dev server runs on `http://localhost:5173` and proxies API requests to `http://localhost:5001`.
|
||||
|
||||
### WebUI build
|
||||
|
||||
Build artifacts are located in `static/admin/`.
|
||||
|
||||
**Automatic build (recommended)**:
|
||||
- Vercel builds the WebUI during deployment (see `vercel.json` `buildCommand`)
|
||||
- The GitHub Actions WebUI build workflow is disabled
|
||||
- `static/admin/` build artifacts are no longer committed
|
||||
|
||||
**Manual build**:
|
||||
```bash
|
||||
# Option 1: use script
|
||||
./scripts/build-webui.sh
|
||||
|
||||
# Option 2: run directly
|
||||
cd webui
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
> **Contributor note**: No manual build is required after modifying WebUI; Vercel deploys will build it automatically.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment (Recommended)
|
||||
|
||||
Docker uses a **non-invasive, decoupled design**:
|
||||
- Dockerfile executes standard Python steps and avoids hardcoded project configs
|
||||
- WebUI is built during image build (for non-Vercel deployments)
|
||||
- Configuration lives in environment variables and `.env`
|
||||
- **Rebuild the image to update code without touching Docker config**
|
||||
|
||||
### Quick start (Docker Compose)
|
||||
## 2. Docker Deployment
|
||||
|
||||
```bash
|
||||
# 1. Copy the environment template
|
||||
cp .env.example .env
|
||||
# Edit .env with DS2API_ADMIN_KEY and DS2API_CONFIG_JSON
|
||||
# edit .env
|
||||
|
||||
# 2. Start the service
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
# 4. Rebuild after code updates
|
||||
Rebuild after updates:
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Mount a config file
|
||||
Notes:
|
||||
|
||||
To use `config.json` instead of environment variables:
|
||||
- `Dockerfile` uses multi-stage build (WebUI + Go binary)
|
||||
- Container entry command is `/usr/local/bin/ds2api`
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ds2api:
|
||||
build: .
|
||||
ports:
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- DS2API_ADMIN_KEY=your-admin-key
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
restart: unless-stopped
|
||||
## 3. Vercel Deployment
|
||||
|
||||
- Serverless entry: `api/index.go`
|
||||
- Rewrites and cache headers: `vercel.json`
|
||||
- Legacy `builds` has been removed to avoid the `unused-build-settings` warning
|
||||
|
||||
Minimum environment variables:
|
||||
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON` (raw JSON or Base64)
|
||||
|
||||
Optional:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
- `VERCEL_TEAM_ID`
|
||||
- `DS2API_ACCOUNT_MAX_INFLIGHT` (per-account inflight limit, default `2`)
|
||||
- `DS2API_ACCOUNT_CONCURRENCY` (alias of the same setting)
|
||||
|
||||
Recommended concurrency is computed dynamically as `account_count * per_account_inflight_limit` (default is `account_count * 2`).
|
||||
|
||||
After deploy, verify:
|
||||
|
||||
- `/healthz`
|
||||
- `/v1/models`
|
||||
- `/admin`
|
||||
|
||||
## 3.1 GitHub Release Automation
|
||||
|
||||
This repo includes `.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- Triggers only on Release `published`
|
||||
- Does not run on `push`
|
||||
- Builds Linux/macOS/Windows archives and uploads them to Release Assets
|
||||
- Generates `sha256sums.txt` for integrity checks
|
||||
|
||||
## 4. Reverse Proxy (Nginx)
|
||||
|
||||
Disable buffering for SSE:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nodelay on;
|
||||
}
|
||||
```
|
||||
|
||||
### Docker CLI deployment
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t ds2api:latest .
|
||||
|
||||
# Run with env variables
|
||||
docker run -d \
|
||||
--name ds2api \
|
||||
-p 5001:5001 \
|
||||
-e DS2API_ADMIN_KEY=your-admin-key \
|
||||
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
|
||||
--restart unless-stopped \
|
||||
ds2api:latest
|
||||
|
||||
# Or mount a config file
|
||||
docker run -d \
|
||||
--name ds2api \
|
||||
-p 5001:5001 \
|
||||
-e DS2API_ADMIN_KEY=your-admin-key \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
--restart unless-stopped \
|
||||
ds2api:latest
|
||||
```
|
||||
|
||||
### Development mode (hot reload)
|
||||
|
||||
```bash
|
||||
# Use the dev compose file to enable hot reload
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Development mode:
|
||||
- Source code is mounted into the container
|
||||
- Log level set to DEBUG
|
||||
- Reads local `config.json`
|
||||
|
||||
### Maintenance commands
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f ds2api
|
||||
|
||||
# Restart
|
||||
docker-compose restart
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
|
||||
# Full rebuild (clear cache)
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Using systemd (Linux)
|
||||
|
||||
1. **Create the service file**
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/ds2api.service
|
||||
```
|
||||
## 5. systemd Example (Linux)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DS2API Service
|
||||
Description=DS2API (Go)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/ds2api
|
||||
ExecStart=/usr/bin/python3 app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=PORT=5001
|
||||
Environment=DS2API_ADMIN_KEY=your-admin-key
|
||||
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
|
||||
Environment=DS2API_ADMIN_KEY=admin
|
||||
ExecStart=/opt/ds2api/ds2api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
2. **Start the service**
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ds2api
|
||||
sudo systemctl start ds2api
|
||||
```
|
||||
|
||||
3. **Check status**
|
||||
|
||||
```bash
|
||||
sudo systemctl status ds2api
|
||||
sudo journalctl -u ds2api -f
|
||||
```
|
||||
|
||||
### Nginx reverse proxy
|
||||
## 6. Post-Deploy Checks
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
|
||||
# SSL configuration (recommended)
|
||||
# listen 443 ssl http2;
|
||||
# ssl_certificate /path/to/cert.pem;
|
||||
# ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Disable buffering for SSE
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# Connection settings
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE timeouts
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# Chunked transfer
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: What if account validation fails?
|
||||
|
||||
**A**: Check the following:
|
||||
1. Confirm the DeepSeek account password is correct
|
||||
2. Ensure the account is not banned or requires verification
|
||||
3. Log in once in a browser
|
||||
4. Check logs for detailed errors
|
||||
|
||||
### Q: Streaming responses disconnect?
|
||||
|
||||
**A**:
|
||||
1. Check Nginx / reverse proxy config and ensure `proxy_buffering` is off
|
||||
2. Increase `proxy_read_timeout`
|
||||
3. Verify network stability
|
||||
|
||||
### Q: Configuration lost after Vercel deploy?
|
||||
|
||||
**A**:
|
||||
1. Ensure you clicked "Sync to Vercel"
|
||||
2. Verify the Vercel token is valid and unexpired
|
||||
3. Ensure the project ID is correct
|
||||
|
||||
### Q: How to update to the latest version?
|
||||
|
||||
**Local deployment**:
|
||||
```bash
|
||||
git pull origin main
|
||||
pip install -r requirements.txt
|
||||
# Restart the service
|
||||
curl -s http://127.0.0.1:5001/healthz
|
||||
curl -s http://127.0.0.1:5001/readyz
|
||||
curl -s http://127.0.0.1:5001/v1/models
|
||||
```
|
||||
|
||||
**Docker deployment**:
|
||||
If admin UI is required:
|
||||
|
||||
```bash
|
||||
# Pull the latest code
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and start (Docker config unchanged)
|
||||
docker-compose up -d --build
|
||||
curl -s http://127.0.0.1:5001/admin
|
||||
```
|
||||
|
||||
**Vercel deployment**:
|
||||
- The project auto-syncs from GitHub
|
||||
- Or trigger a redeploy in the Vercel console
|
||||
|
||||
### Q: How do I view logs?
|
||||
|
||||
**Local dev**:
|
||||
```bash
|
||||
# Set log level
|
||||
export LOG_LEVEL=DEBUG
|
||||
python dev.py
|
||||
```
|
||||
|
||||
**Vercel**:
|
||||
- Vercel console → Project → Deployments → Logs
|
||||
|
||||
### Q: Token counting is inaccurate?
|
||||
|
||||
**A**: DS2API uses a heuristic estimate (characters / 4). The official OpenAI tokenizer may differ, so treat it as a reference only.
|
||||
|
||||
---
|
||||
|
||||
## Get Help
|
||||
|
||||
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
|
||||
- **Docs**: https://github.com/CJackHwang/ds2api
|
||||
|
||||
435
DEPLOY.md
435
DEPLOY.md
@@ -1,410 +1,155 @@
|
||||
# DS2API 部署指南
|
||||
# DS2API 部署指南(Go)
|
||||
|
||||
语言 / Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
|
||||
|
||||
本文档详细介绍 DS2API 的各种部署方式。
|
||||
本指南基于当前 Go 代码库。
|
||||
|
||||
---
|
||||
## 部署方式
|
||||
|
||||
## 目录
|
||||
- 本地运行:`go run ./cmd/ds2api`
|
||||
- Docker:`docker-compose up -d`
|
||||
- Vercel:`api/index.go` serverless 入口
|
||||
- Linux 服务化:systemd
|
||||
|
||||
- [Vercel 部署(推荐)](#vercel-部署推荐)
|
||||
- [Docker 部署(推荐)](#docker-部署推荐)
|
||||
- [本地开发](#本地开发)
|
||||
- [生产环境部署](#生产环境部署)
|
||||
- [常见问题](#常见问题)
|
||||
## 0. 前置要求
|
||||
|
||||
---
|
||||
- Go 1.24+
|
||||
- Node.js 20+(仅在需要本地构建 WebUI 时)
|
||||
- `config.json` 或 `DS2API_CONFIG_JSON`
|
||||
|
||||
## Vercel 部署(推荐)
|
||||
|
||||
### 一键部署
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
|
||||
|
||||
### 部署步骤
|
||||
|
||||
1. **点击部署按钮**
|
||||
- 登录你的 GitHub 账号
|
||||
- 授权 Vercel 访问
|
||||
|
||||
2. **设置环境变量**
|
||||
- `DS2API_ADMIN_KEY`: 管理面板密码(**必填**)
|
||||
|
||||
3. **等待部署完成**
|
||||
- Vercel 会自动构建并部署项目
|
||||
- 部署完成后获得访问 URL
|
||||
|
||||
4. **配置账号**
|
||||
- 访问 `https://your-project.vercel.app/admin`
|
||||
- 输入管理密码登录
|
||||
- 添加 DeepSeek 账号
|
||||
- 设置自定义 API Key
|
||||
|
||||
5. **同步配置**
|
||||
- 点击「同步到 Vercel」按钮
|
||||
- 首次需要输入 Vercel Token 和 Project ID
|
||||
- 同步成功后配置会持久化
|
||||
|
||||
### 获取 Vercel 凭证
|
||||
|
||||
**Vercel Token**:
|
||||
1. 访问 https://vercel.com/account/tokens
|
||||
2. 点击 "Create Token"
|
||||
3. 设置名称和有效期
|
||||
4. 复制生成的 Token
|
||||
|
||||
**Project ID**:
|
||||
1. 进入 Vercel 项目页面
|
||||
2. 点击 Settings -> General
|
||||
3. 复制 "Project ID"
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.9+
|
||||
- Node.js 18+ (WebUI 开发)
|
||||
- pip
|
||||
|
||||
### 快速开始
|
||||
## 1. 本地运行
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 安装 Python 依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 配置账号
|
||||
cp config.example.json config.json
|
||||
# 编辑 config.json,填入 DeepSeek 账号信息
|
||||
# 编辑 config.json
|
||||
|
||||
# 4. 启动服务
|
||||
python dev.py
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
### 配置文件示例
|
||||
默认监听 `5001`,可通过 `PORT` 覆盖。
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["my-api-key-1", "my-api-key-2"],
|
||||
"accounts": [
|
||||
{
|
||||
"email": "your-email@example.com",
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
},
|
||||
{
|
||||
"mobile": "12345678901",
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `keys`: 自定义 API Key,用于调用本服务的接口
|
||||
- `accounts`: DeepSeek 网页版账号
|
||||
- 支持 `email` 或 `mobile` 登录
|
||||
- `token` 留空,系统会自动获取
|
||||
|
||||
### WebUI 开发
|
||||
构建 WebUI(可选,仅当 `/admin` 缺少静态文件时):
|
||||
|
||||
```bash
|
||||
# 进入 WebUI 目录
|
||||
cd webui
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API 请求到后端 `http://localhost:5001`。
|
||||
|
||||
### WebUI 构建
|
||||
|
||||
WebUI 构建产物位于 `static/admin/` 目录。
|
||||
|
||||
**自动构建(推荐)**:
|
||||
- 当前由 Vercel 在部署时执行 WebUI 构建(见 `vercel.json` 的 `buildCommand`)
|
||||
- GitHub Actions 的 WebUI 自动构建流程已关闭
|
||||
- `static/admin/` 构建产物不再提交到仓库
|
||||
|
||||
**手动构建**:
|
||||
```bash
|
||||
# 方式1:使用脚本
|
||||
./scripts/build-webui.sh
|
||||
|
||||
# 方式2:直接执行
|
||||
cd webui
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
> **贡献者注意**:修改 WebUI 后无需手动构建,Vercel 部署会自动构建。
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署(推荐)
|
||||
|
||||
Docker 部署采用**零侵入、解耦设计**:
|
||||
- Dockerfile 仅执行标准 Python 项目操作,不硬编码任何项目特定配置
|
||||
- 构建镜像时会一并构建 WebUI(便于非 Vercel 部署直接访问管理面板)
|
||||
- 所有配置通过环境变量和 `.env` 文件管理
|
||||
- **主代码更新时只需重新构建镜像,无需修改 Docker 配置**
|
||||
|
||||
### 快速开始(Docker Compose)
|
||||
## 2. Docker 部署
|
||||
|
||||
```bash
|
||||
# 1. 复制环境变量模板
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
|
||||
# 编辑 .env
|
||||
|
||||
# 2. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
# 4. 主代码更新后重新构建
|
||||
更新镜像:
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 配置文件挂载方式
|
||||
说明:
|
||||
|
||||
如需使用 `config.json` 而非环境变量:
|
||||
- `Dockerfile` 使用多阶段构建(WebUI + Go 二进制)
|
||||
- 容器内默认启动命令:`/usr/local/bin/ds2api`
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ds2api:
|
||||
build: .
|
||||
ports:
|
||||
- "5001:5001"
|
||||
environment:
|
||||
- DS2API_ADMIN_KEY=your-admin-key
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
restart: unless-stopped
|
||||
## 3. Vercel 部署
|
||||
|
||||
- serverless 入口:`api/index.go`
|
||||
- 路由与缓存头:`vercel.json`
|
||||
- 已移除 legacy `builds` 字段,避免 `unused-build-settings` 警告
|
||||
|
||||
至少配置环境变量:
|
||||
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON`(JSON 或 Base64)
|
||||
|
||||
可选:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
- `VERCEL_TEAM_ID`
|
||||
- `DS2API_ACCOUNT_MAX_INFLIGHT`(每账号并发上限,默认 `2`)
|
||||
- `DS2API_ACCOUNT_CONCURRENCY`(同上别名)
|
||||
|
||||
并发建议值会动态按 `账号数量 × 每账号并发上限` 计算(默认即 `账号数量 × 2`)。
|
||||
|
||||
部署后建议先访问:
|
||||
|
||||
- `/healthz`
|
||||
- `/v1/models`
|
||||
- `/admin`
|
||||
|
||||
## 3.1 GitHub Release 自动构建
|
||||
|
||||
仓库包含 `.github/workflows/release-artifacts.yml`:
|
||||
|
||||
- 仅在 Release `published` 时触发
|
||||
- 不在 `push` 时触发
|
||||
- 自动构建 Linux/macOS/Windows 二进制包并上传到 Release Assets
|
||||
- 生成 `sha256sums.txt` 供校验
|
||||
|
||||
## 4. 反向代理(Nginx)
|
||||
|
||||
如果在 Nginx 后挂载,建议关闭缓冲以保证 SSE:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nodelay on;
|
||||
}
|
||||
```
|
||||
|
||||
### Docker 命令行部署
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t ds2api:latest .
|
||||
|
||||
# 使用环境变量运行
|
||||
docker run -d \
|
||||
--name ds2api \
|
||||
-p 5001:5001 \
|
||||
-e DS2API_ADMIN_KEY=your-admin-key \
|
||||
-e DS2API_CONFIG_JSON='{"keys":["api-key"],"accounts":[...]}' \
|
||||
--restart unless-stopped \
|
||||
ds2api:latest
|
||||
|
||||
# 或使用配置文件挂载
|
||||
docker run -d \
|
||||
--name ds2api \
|
||||
-p 5001:5001 \
|
||||
-e DS2API_ADMIN_KEY=your-admin-key \
|
||||
-v $(pwd)/config.json:/app/config.json:ro \
|
||||
--restart unless-stopped \
|
||||
ds2api:latest
|
||||
```
|
||||
|
||||
### 开发模式(热重载)
|
||||
|
||||
```bash
|
||||
# 使用开发配置启动,代码修改实时生效
|
||||
docker-compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
开发模式特性:
|
||||
- 源代码挂载到容器,修改即时生效
|
||||
- 日志级别设为 DEBUG
|
||||
- 自动读取本地 `config.json`
|
||||
|
||||
### 维护命令
|
||||
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f ds2api
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 完全重建(清除缓存)
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 生产环境部署
|
||||
|
||||
### 使用 systemd (Linux)
|
||||
|
||||
1. **创建服务文件**
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/ds2api.service
|
||||
```
|
||||
## 5. systemd 示例(Linux)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=DS2API Service
|
||||
Description=DS2API (Go)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/ds2api
|
||||
ExecStart=/usr/bin/python3 app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=PORT=5001
|
||||
Environment=DS2API_ADMIN_KEY=your-admin-key
|
||||
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
|
||||
Environment=DS2API_ADMIN_KEY=admin
|
||||
ExecStart=/opt/ds2api/ds2api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
2. **启动服务**
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ds2api
|
||||
sudo systemctl start ds2api
|
||||
```
|
||||
|
||||
3. **查看状态**
|
||||
|
||||
```bash
|
||||
sudo systemctl status ds2api
|
||||
sudo journalctl -u ds2api -f
|
||||
```
|
||||
|
||||
### Nginx 反向代理
|
||||
## 6. 部署后检查
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
|
||||
# SSL 配置(推荐)
|
||||
# listen 443 ssl http2;
|
||||
# ssl_certificate /path/to/cert.pem;
|
||||
# ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# 关闭缓冲,支持 SSE
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 连接设置
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE 超时设置
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# 分块传输
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 账号验证失败怎么办?
|
||||
|
||||
**A**: 检查以下几点:
|
||||
1. 确认 DeepSeek 账号密码正确
|
||||
2. 检查账号是否被封禁或需要验证
|
||||
3. 尝试在浏览器中手动登录一次
|
||||
4. 查看日志获取详细错误信息
|
||||
|
||||
### Q: 流式响应断开怎么办?
|
||||
|
||||
**A**:
|
||||
1. 检查 Nginx/反向代理配置,确保关闭了 `proxy_buffering`
|
||||
2. 增加 `proxy_read_timeout` 超时时间
|
||||
3. 检查网络连接稳定性
|
||||
|
||||
### Q: Vercel 部署后配置丢失?
|
||||
|
||||
**A**:
|
||||
1. 确保点击了「同步到 Vercel」按钮
|
||||
2. 检查 Vercel Token 是否正确且未过期
|
||||
3. 确认 Project ID 正确
|
||||
|
||||
### Q: 如何更新到新版本?
|
||||
|
||||
**本地部署**:
|
||||
```bash
|
||||
git pull origin main
|
||||
pip install -r requirements.txt
|
||||
# 重启服务
|
||||
curl -s http://127.0.0.1:5001/healthz
|
||||
curl -s http://127.0.0.1:5001/readyz
|
||||
curl -s http://127.0.0.1:5001/v1/models
|
||||
```
|
||||
|
||||
**Docker 部署**:
|
||||
如果你依赖管理台接口,再检查:
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 重新构建并启动(无需修改 Docker 配置)
|
||||
docker-compose up -d --build
|
||||
curl -s http://127.0.0.1:5001/admin
|
||||
```
|
||||
|
||||
**Vercel 部署**:
|
||||
- 项目会自动从 GitHub 同步更新
|
||||
- 或在 Vercel 控制台手动触发重新部署
|
||||
|
||||
### Q: 如何查看日志?
|
||||
|
||||
**本地开发**:
|
||||
```bash
|
||||
# 设置日志级别
|
||||
export LOG_LEVEL=DEBUG
|
||||
python dev.py
|
||||
```
|
||||
|
||||
**Vercel**:
|
||||
- 访问 Vercel 控制台 -> 项目 -> Deployments -> Logs
|
||||
|
||||
### Q: Token 计数不准确?
|
||||
|
||||
**A**: DS2API 使用估算方式计算 token 数量(字符数 / 4),与 OpenAI 官方的 tokenizer 可能有差异,仅供参考。
|
||||
|
||||
---
|
||||
|
||||
## 获取帮助
|
||||
|
||||
- **GitHub Issues**: https://github.com/CJackHwang/ds2api/issues
|
||||
- **文档**: https://github.com/CJackHwang/ds2api
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,33 +1,24 @@
|
||||
# DS2API Docker 镜像
|
||||
# 采用极简、零侵入设计,所有配置通过环境变量传递
|
||||
# 主代码更新时只需重新构建镜像,无需修改 Dockerfile
|
||||
|
||||
FROM node:20 AS webui-builder
|
||||
|
||||
WORKDIR /app/webui
|
||||
|
||||
COPY webui/package.json webui/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY webui ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
FROM golang:1.24 AS go-builder
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖(利用 Docker 缓存层)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制整个项目(保留原始目录结构)
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ds2api ./cmd/ds2api
|
||||
|
||||
# 拷贝 WebUI 构建产物(非 Vercel / Docker 部署可直接使用)
|
||||
FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates wget && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
|
||||
COPY --from=go-builder /app/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
|
||||
COPY --from=go-builder /app/config.example.json /app/config.example.json
|
||||
COPY --from=webui-builder /app/static/admin /app/static/admin
|
||||
|
||||
# 暴露服务端口
|
||||
EXPOSE 5001
|
||||
|
||||
# 启动命令(依赖项目自身的启动逻辑)
|
||||
CMD ["python", "app.py"]
|
||||
CMD ["/usr/local/bin/ds2api"]
|
||||
|
||||
301
README.MD
301
README.MD
@@ -4,98 +4,126 @@
|
||||

|
||||

|
||||
[](version.txt)
|
||||
[](DEPLOY.md#docker-部署推荐)
|
||||
[](DEPLOY.md)
|
||||
|
||||
语言 / Language: [中文](README.MD) | [English](README.en.md)
|
||||
|
||||
将 DeepSeek 免费对话版转换为 **OpenAI & Claude 兼容 API**,支持多账号轮询、自动 Token 刷新、可视化管理界面。
|
||||
将 DeepSeek Web 对话能力转换为 OpenAI 与 Claude 兼容 API。当前仓库后端为 **Go 全量实现**,前端保留 React WebUI(构建产物托管于 `static/admin`)。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
## 当前实现边界
|
||||
|
||||
- 后端:Go(`cmd/`, `api/`, `internal/`),不再依赖 Python 运行时
|
||||
- 前端:React 管理台(源码在 `webui/`,运行时托管静态构建)
|
||||
- 部署:本地运行、Docker、Vercel Serverless
|
||||
|
||||
## 核心能力
|
||||
|
||||
## ✨ 特性
|
||||
- OpenAI 兼容:`/v1/models`、`/v1/chat/completions`
|
||||
- Claude 兼容:`/anthropic/v1/models`、`/anthropic/v1/messages`、`/anthropic/v1/messages/count_tokens`
|
||||
- 多账号轮询与自动 token 刷新
|
||||
- DeepSeek PoW(WASM)计算
|
||||
- Admin API:配置管理、账号测试、导入导出、Vercel 同步
|
||||
- WebUI:`/admin` 单页应用托管
|
||||
- 运维探针:`/healthz`、`/readyz`
|
||||
|
||||
- 🔄 **双协议兼容** - 同时支持 OpenAI 和 Claude (Anthropic) API 格式
|
||||
- 🚀 **多账号轮询** - Round-Robin 负载均衡,支持高并发场景
|
||||
- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护
|
||||
- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel 配置
|
||||
- 🌍 **多语言切换** - WebUI 内置中英双语,可随时切换
|
||||
- 🔍 **联网搜索** - 支持 DeepSeek 原生搜索增强模式
|
||||
- 🧠 **深度思考** - 支持推理模式,输出思考过程
|
||||
- 🛠️ **工具调用** - 兼容 OpenAI Function Calling 格式
|
||||
- ☁️ **Vercel 一键部署** - 无需服务器,快速上线
|
||||
## 模型支持
|
||||
|
||||
## 📋 模型支持
|
||||
### OpenAI 接口
|
||||
|
||||
### OpenAI 兼容接口 (`/v1/chat/completions`)
|
||||
| 模型 | thinking | search |
|
||||
| --- | --- | --- |
|
||||
| `deepseek-chat` | false | false |
|
||||
| `deepseek-reasoner` | true | false |
|
||||
| `deepseek-chat-search` | false | true |
|
||||
| `deepseek-reasoner-search` | true | true |
|
||||
|
||||
| 模型 | 深度思考 | 联网搜索 | 说明 |
|
||||
|-----|:--------:|:--------:|------|
|
||||
| `deepseek-chat` | ❌ | ❌ | 标准对话模式 |
|
||||
| `deepseek-reasoner` | ✅ | ❌ | 推理模式(输出思考过程) |
|
||||
| `deepseek-chat-search` | ❌ | ✅ | 联网搜索模式 |
|
||||
| `deepseek-reasoner-search` | ✅ | ✅ | 推理 + 联网搜索 |
|
||||
### Claude 接口
|
||||
|
||||
### Claude 兼容接口 (`/anthropic/v1/messages`)
|
||||
| 模型 | 默认映射 |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-4-20250514` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-fast` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` |
|
||||
|
||||
| 模型 | 说明 |
|
||||
|-----|------|
|
||||
| `claude-sonnet-4-20250514` | 映射到 deepseek-chat(标准模式) |
|
||||
| `claude-sonnet-4-20250514-fast` | 映射到 deepseek-chat(快速模式) |
|
||||
| `claude-sonnet-4-20250514-slow` | 映射到 deepseek-reasoner(推理模式) |
|
||||
可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射。
|
||||
|
||||
> **提示**:Claude 接口实际调用的是 DeepSeek,响应格式会自动转换为 Anthropic 标准格式。
|
||||
## 快速开始
|
||||
|
||||
## 🚀 快速开始
|
||||
### 1) 本地运行
|
||||
|
||||
### 方式一:Vercel 部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
|
||||
|
||||
1. 点击上方按钮,设置管理密码 `DS2API_ADMIN_KEY`
|
||||
2. 部署完成后访问 `/admin` 管理界面
|
||||
3. 添加 DeepSeek 账号和自定义 API Key
|
||||
4. 点击「同步到 Vercel」保存配置
|
||||
|
||||
> **首次同步会自动验证账号并保存 Token,后续操作无需重复输入凭证。**
|
||||
|
||||
### 方式二:本地开发
|
||||
要求:Go 1.24+
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 配置账号
|
||||
cp config.example.json config.json
|
||||
# 编辑 config.json,添加 DeepSeek 账号信息
|
||||
# 编辑 config.json
|
||||
|
||||
# 4. 启动服务
|
||||
python dev.py
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
服务启动后访问 `http://localhost:5001`
|
||||
默认地址:`http://localhost:5001`
|
||||
|
||||
## ⚙️ 配置说明
|
||||
如果访问 `/admin` 提示未构建 WebUI,请执行:
|
||||
|
||||
### 环境变量
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
| 变量 | 说明 | 必填 |
|
||||
|-----|------|:----:|
|
||||
| `DS2API_ADMIN_KEY` | 管理面板密码 | Vercel 必填 |
|
||||
| `DS2API_CONFIG_JSON` | 配置 JSON 或 Base64 编码 | 可选 |
|
||||
| `VERCEL_TOKEN` | Vercel API Token(用于同步) | 可选 |
|
||||
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | 可选 |
|
||||
| `PORT` | 服务端口(默认 5001) | 可选 |
|
||||
### 2) Docker 运行
|
||||
|
||||
### 配置文件格式 (`config.json`)
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
|
||||
docker-compose up -d
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 3) Vercel 部署
|
||||
|
||||
- 入口:`api/index.go`
|
||||
- 路由重写:`vercel.json`
|
||||
- 至少配置:
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON`(JSON 字符串或 Base64)
|
||||
|
||||
说明:`vercel.json` 已移除 legacy `builds` 配置,避免部署时出现
|
||||
`unused-build-settings` 警告,并使用当前推荐的函数路由模式。
|
||||
|
||||
## Release 自动构建产物(GitHub Actions)
|
||||
|
||||
仓库内置工作流:`.github/workflows/release-artifacts.yml`
|
||||
|
||||
- 触发条件:仅在 GitHub Release `published` 时触发
|
||||
- 不会在普通 `push` 时构建
|
||||
- 构建内容:多平台二进制包(Linux/macOS/Windows)+ `sha256sums.txt`
|
||||
- 每个压缩包包含:
|
||||
- `ds2api` 可执行文件(Windows 为 `ds2api.exe`)
|
||||
- `static/admin`(WebUI 构建产物)
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm`
|
||||
- `config.example.json`、`.env.example`
|
||||
- `README.MD`、`README.en.md`、`LICENSE`
|
||||
|
||||
维护者发布步骤:
|
||||
|
||||
1. 在 GitHub 创建并发布 Release(带 tag,如 `v1.7.0`)
|
||||
2. 等待 Actions 工作流 `Release Artifacts` 完成
|
||||
3. 在 Release 的 Assets 下载对应平台压缩包
|
||||
|
||||
下载后运行示例(Linux/macOS):
|
||||
|
||||
```bash
|
||||
tar -xzf ds2api_v1.7.0_linux_amd64.tar.gz
|
||||
cd ds2api_v1.7.0_linux_amd64
|
||||
cp config.example.json config.json
|
||||
./ds2api
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### `config.json` 示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -111,125 +139,68 @@ python dev.py
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
}
|
||||
]
|
||||
],
|
||||
"claude_model_mapping": {
|
||||
"fast": "deepseek-chat",
|
||||
"slow": "deepseek-reasoner"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:
|
||||
> - `keys`: 自定义的 API 密钥,用于调用本服务
|
||||
> - `accounts`: DeepSeek 网页版账号,支持邮箱或手机号登录
|
||||
> - `token`: 留空即可,系统会自动获取并刷新
|
||||
### 环境变量(核心)
|
||||
|
||||
## 📡 API 使用
|
||||
| 变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `PORT` | 服务端口,默认 `5001` |
|
||||
| `LOG_LEVEL` | 日志级别:`DEBUG/INFO/WARN/ERROR` |
|
||||
| `DS2API_ACCOUNT_MAX_INFLIGHT` | 每个账号最大并发 in-flight 请求数,默认 `2` |
|
||||
| `DS2API_ACCOUNT_CONCURRENCY` | 同上别名(兼容旧写法) |
|
||||
| `DS2API_ADMIN_KEY` | Admin 登录密钥,默认 `admin` |
|
||||
| `DS2API_JWT_SECRET` | Admin JWT 签名密钥(可选) |
|
||||
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数,默认 `24` |
|
||||
| `DS2API_CONFIG_PATH` | 配置文件路径,默认 `config.json` |
|
||||
| `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) |
|
||||
| `DS2API_WASM_PATH` | PoW wasm 文件路径 |
|
||||
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 |
|
||||
| `VERCEL_TOKEN` | Vercel 同步 token(可选) |
|
||||
| `VERCEL_PROJECT_ID` | Vercel 项目 ID(可选) |
|
||||
| `VERCEL_TEAM_ID` | Vercel 团队 ID(可选) |
|
||||
|
||||
完整 API 文档请参阅 **[API.md](API.md)**
|
||||
## 鉴权与账号模式
|
||||
|
||||
### 快速示例
|
||||
调用业务接口时(`/v1/*`, `/anthropic/*`)支持两种模式:
|
||||
|
||||
**获取模型列表**:
|
||||
```bash
|
||||
curl http://localhost:5001/v1/models
|
||||
```
|
||||
1. 托管账号模式:`Bearer` 或 `x-api-key` 使用 `config.keys` 中的 key。
|
||||
2. 直通 token 模式:当传入 token 不在 `config.keys` 中时,服务直接把它当作 DeepSeek token 使用。
|
||||
|
||||
**OpenAI 格式调用**:
|
||||
```bash
|
||||
curl http://localhost:5001/v1/chat/completions \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role": "user", "content": "你好"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
可选请求头:`X-Ds2-Target-Account`,用于指定托管账号。
|
||||
|
||||
**Claude 格式调用**:
|
||||
```bash
|
||||
curl http://localhost:5001/anthropic/v1/messages \
|
||||
-H "x-api-key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "你好"}]
|
||||
}'
|
||||
```
|
||||
## 并发建议值
|
||||
|
||||
### Python SDK 使用
|
||||
- 系统建议并发值按账号池动态计算:`账号数量 × 每账号并发上限`
|
||||
- 默认每账号并发上限是 `2`,因此默认建议值是 `账号数量 × 2`
|
||||
- 可通过 `DS2API_ACCOUNT_MAX_INFLIGHT`(或 `DS2API_ACCOUNT_CONCURRENCY`)手动覆盖每账号并发上限
|
||||
- `GET /admin/queue/status` 会返回 `max_inflight_per_account` 与 `recommended_concurrency`
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
## Tool Call 适配说明
|
||||
|
||||
client = OpenAI(
|
||||
api_key="your-api-key",
|
||||
base_url="http://localhost:5001/v1"
|
||||
)
|
||||
当前实现对 toolcall 做了防泄漏处理:
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-reasoner",
|
||||
messages=[{"role": "user", "content": "请解释量子纠缠"}],
|
||||
stream=True
|
||||
)
|
||||
- `tools` + `stream=true` 时,服务端会先缓冲正文片段
|
||||
- 若识别到工具调用,会只输出结构化 `tool_calls`,不透传原始 JSON 文本
|
||||
- 若最终不是工具调用,再一次性输出普通文本
|
||||
- 解析器支持混合文本、fenced JSON、`function.arguments` 字符串等格式
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
## 文档与测试
|
||||
|
||||
## 🔧 部署配置
|
||||
|
||||
### Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:Docker 部署
|
||||
- API 文档:`API.md` / `API.en.md`
|
||||
- 部署文档:`DEPLOY.md` / `DEPLOY.en.md`
|
||||
- 贡献指南:`CONTRIBUTING.md` / `CONTRIBUTING.en.md`
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库并进入目录
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
|
||||
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker-compose logs -f
|
||||
go test ./...
|
||||
```
|
||||
|
||||
> **Docker 优势**:零侵入设计,主代码更新只需 `docker-compose up -d --build`,无需修改 Docker 配置。详见 [DEPLOY.md](DEPLOY.md#docker-部署推荐)。
|
||||
## 免责声明
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
**本项目基于逆向工程实现,服务稳定性无法保证。**
|
||||
|
||||
- 仅供学习研究使用,**禁止商业用途或对外提供服务**
|
||||
- 建议正式项目使用 [DeepSeek 官方 API](https://platform.deepseek.com/)
|
||||
- 使用本项目产生的任何风险由用户自行承担
|
||||
|
||||
## 📜 鸣谢
|
||||
|
||||
本项目基于以下开源项目:
|
||||
|
||||
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
|
||||
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
|
||||
|
||||
## 📊 Star History
|
||||
|
||||
[](https://star-history.com/#CJackHwang/ds2api&Date)
|
||||
本项目基于逆向方式实现,仅供学习与研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。
|
||||
|
||||
299
README.en.md
299
README.en.md
@@ -4,96 +4,126 @@
|
||||

|
||||

|
||||
[](version.txt)
|
||||
[](DEPLOY.md#docker-deployment-recommended)
|
||||
[](DEPLOY.en.md)
|
||||
|
||||
Language: [中文](README.MD) | [English](README.en.md)
|
||||
|
||||
Convert DeepSeek Web into an **OpenAI & Claude compatible API**, with multi-account rotation, automatic token refresh, and a visual admin console.
|
||||
DS2API converts DeepSeek Web chat capability into OpenAI-compatible and Claude-compatible APIs. The current repository is **Go backend only** with the existing React WebUI kept as static assets under `static/admin`.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
## Implementation Boundary
|
||||
|
||||
## ✨ Features
|
||||
- Backend: Go (`cmd/`, `api/`, `internal/`), no Python runtime
|
||||
- Frontend: React admin panel (`webui/` source, static build served at runtime)
|
||||
- Deployment: local run, Docker, Vercel serverless
|
||||
|
||||
- 🔄 **Dual-protocol support** - OpenAI and Claude (Anthropic) compatible APIs
|
||||
- 🚀 **Multi-account rotation** - Round-robin load balancing for high concurrency
|
||||
- 🔐 **Automatic token refresh** - Re-auth on expiry without manual maintenance
|
||||
- 🌐 **WebUI management** - Add accounts, test APIs, and sync Vercel settings visually
|
||||
- 🌍 **Language toggle** - Built-in Chinese and English UI switcher
|
||||
- 🔍 **Web search** - DeepSeek native search enhancement mode
|
||||
- 🧠 **Deep reasoning** - Reasoning mode with trace output
|
||||
- 🛠️ **Tool calling** - OpenAI Function Calling compatible
|
||||
- ☁️ **One-click Vercel deploy** - No server required
|
||||
## Key Capabilities
|
||||
|
||||
## 📋 Model Support
|
||||
- OpenAI-compatible endpoints: `GET /v1/models`, `POST /v1/chat/completions`
|
||||
- Claude-compatible endpoints: `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens`
|
||||
- Multi-account rotation and automatic token refresh
|
||||
- DeepSeek PoW solving via WASM
|
||||
- Admin API: config management, account tests, import/export, Vercel sync
|
||||
- WebUI SPA hosting at `/admin`
|
||||
- Health probes: `GET /healthz`, `GET /readyz`
|
||||
|
||||
### OpenAI compatible endpoint (`/v1/chat/completions`)
|
||||
## Model Support
|
||||
|
||||
| Model | Reasoning | Search | Notes |
|
||||
|-----|:--------:|:------:|------|
|
||||
| `deepseek-chat` | ❌ | ❌ | Standard chat |
|
||||
| `deepseek-reasoner` | ✅ | ❌ | Reasoning (shows trace) |
|
||||
| `deepseek-chat-search` | ❌ | ✅ | Web search mode |
|
||||
| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search |
|
||||
### OpenAI endpoint
|
||||
|
||||
### Claude compatible endpoint (`/anthropic/v1/messages`)
|
||||
| Model | thinking | search |
|
||||
| --- | --- | --- |
|
||||
| `deepseek-chat` | false | false |
|
||||
| `deepseek-reasoner` | true | false |
|
||||
| `deepseek-chat-search` | false | true |
|
||||
| `deepseek-reasoner-search` | true | true |
|
||||
|
||||
| Model | Notes |
|
||||
|-----|------|
|
||||
| `claude-sonnet-4-20250514` | Maps to deepseek-chat (standard) |
|
||||
| `claude-sonnet-4-20250514-fast` | Maps to deepseek-chat (fast) |
|
||||
| `claude-sonnet-4-20250514-slow` | Maps to deepseek-reasoner (reasoning) |
|
||||
### Claude endpoint
|
||||
|
||||
> **Tip**: The Claude endpoint actually calls DeepSeek and returns Anthropic-format responses.
|
||||
| Model | Default mapping |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-4-20250514` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-fast` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` |
|
||||
|
||||
## 🚀 Quick Start
|
||||
You can override mapping via `claude_mapping` or `claude_model_mapping` in config.
|
||||
|
||||
### Option 1: Vercel deployment (recommended)
|
||||
## Quick Start
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api)
|
||||
### 1) Local run
|
||||
|
||||
1. Click the button above and set `DS2API_ADMIN_KEY`
|
||||
2. After deployment, visit `/admin`
|
||||
3. Add DeepSeek accounts and custom API keys
|
||||
4. Click "Sync to Vercel" to persist configuration
|
||||
|
||||
> **First sync validates accounts and stores tokens automatically.**
|
||||
|
||||
### Option 2: Local development
|
||||
Requirement: Go 1.24+
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. Configure accounts
|
||||
cp config.example.json config.json
|
||||
# Edit config.json to add DeepSeek account info
|
||||
# edit config.json
|
||||
|
||||
# 4. Start the service
|
||||
python dev.py
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
Visit `http://localhost:5001` after startup.
|
||||
Default URL: `http://localhost:5001`
|
||||
|
||||
## ⚙️ Configuration
|
||||
If `/admin` says WebUI not built:
|
||||
|
||||
### Environment variables
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
| Variable | Description | Required |
|
||||
|-----|------|:----:|
|
||||
| `DS2API_ADMIN_KEY` | Admin console password | Required on Vercel |
|
||||
| `DS2API_CONFIG_JSON` | Config JSON or Base64 | Optional |
|
||||
| `VERCEL_TOKEN` | Vercel API token (for sync) | Optional |
|
||||
| `VERCEL_PROJECT_ID` | Vercel project ID | Optional |
|
||||
| `PORT` | Service port (default 5001) | Optional |
|
||||
### 2) Docker
|
||||
|
||||
### Config file format (`config.json`)
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env
|
||||
|
||||
docker-compose up -d
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 3) Vercel
|
||||
|
||||
- Entrypoint: `api/index.go`
|
||||
- Rewrites: `vercel.json`
|
||||
- Minimum env vars:
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON` (raw JSON or Base64)
|
||||
|
||||
Note: legacy `builds` has been removed from `vercel.json` to avoid
|
||||
the `unused-build-settings` warning and to follow the current function routing model.
|
||||
|
||||
## Release Artifact Automation (GitHub Actions)
|
||||
|
||||
Built-in workflow: `.github/workflows/release-artifacts.yml`
|
||||
|
||||
- Trigger: only when a GitHub Release is `published`
|
||||
- No build on normal `push`
|
||||
- Outputs: multi-platform binaries (Linux/macOS/Windows) + `sha256sums.txt`
|
||||
- Each archive includes:
|
||||
- `ds2api` executable (`ds2api.exe` on Windows)
|
||||
- `static/admin` (built WebUI assets)
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm`
|
||||
- `config.example.json`, `.env.example`
|
||||
- `README.MD`, `README.en.md`, `LICENSE`
|
||||
|
||||
Maintainer release flow:
|
||||
|
||||
1. Create and publish a GitHub Release (with tag, e.g. `v1.7.0`)
|
||||
2. Wait for the `Release Artifacts` workflow to finish
|
||||
3. Download the matching archive from Release Assets
|
||||
|
||||
Run from downloaded archive (Linux/macOS):
|
||||
|
||||
```bash
|
||||
tar -xzf ds2api_v1.7.0_linux_amd64.tar.gz
|
||||
cd ds2api_v1.7.0_linux_amd64
|
||||
cp config.example.json config.json
|
||||
./ds2api
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### `config.json` example
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -109,125 +139,68 @@ Visit `http://localhost:5001` after startup.
|
||||
"password": "your-password",
|
||||
"token": ""
|
||||
}
|
||||
]
|
||||
],
|
||||
"claude_model_mapping": {
|
||||
"fast": "deepseek-chat",
|
||||
"slow": "deepseek-reasoner"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Notes**:
|
||||
> - `keys`: Custom API keys for calling this service
|
||||
> - `accounts`: DeepSeek Web accounts (email or mobile)
|
||||
> - `token`: Leave blank; DS2API will fetch and refresh automatically
|
||||
### Core environment variables
|
||||
|
||||
## 📡 API Usage
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `PORT` | Service port, default `5001` |
|
||||
| `LOG_LEVEL` | `DEBUG/INFO/WARN/ERROR` |
|
||||
| `DS2API_ACCOUNT_MAX_INFLIGHT` | Max in-flight requests per managed account, default `2` |
|
||||
| `DS2API_ACCOUNT_CONCURRENCY` | Alias of the same setting (legacy compatibility) |
|
||||
| `DS2API_ADMIN_KEY` | Admin login key, default `admin` |
|
||||
| `DS2API_JWT_SECRET` | Admin JWT signing secret (optional) |
|
||||
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours, default `24` |
|
||||
| `DS2API_CONFIG_PATH` | Config file path, default `config.json` |
|
||||
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) |
|
||||
| `DS2API_WASM_PATH` | PoW wasm path |
|
||||
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir |
|
||||
| `VERCEL_TOKEN` | Vercel sync token (optional) |
|
||||
| `VERCEL_PROJECT_ID` | Vercel project ID (optional) |
|
||||
| `VERCEL_TEAM_ID` | Vercel team ID (optional) |
|
||||
|
||||
See **[API.md](API.md)** for full API documentation.
|
||||
## Auth and Account Modes
|
||||
|
||||
### Quick examples
|
||||
For business endpoints (`/v1/*`, `/anthropic/*`), DS2API supports two modes:
|
||||
|
||||
**List models**:
|
||||
```bash
|
||||
curl http://localhost:5001/v1/models
|
||||
```
|
||||
1. Managed account mode: use a key from `config.keys` via `Authorization: Bearer ...` or `x-api-key`.
|
||||
2. Direct token mode: if the incoming token is not in `config.keys`, DS2API treats it as a DeepSeek token directly.
|
||||
|
||||
**OpenAI-compatible call**:
|
||||
```bash
|
||||
curl http://localhost:5001/v1/chat/completions \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
Optional header: `X-Ds2-Target-Account` to pin one managed account.
|
||||
|
||||
**Claude-compatible call**:
|
||||
```bash
|
||||
curl http://localhost:5001/anthropic/v1/messages \
|
||||
-H "x-api-key: your-api-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'
|
||||
```
|
||||
## Recommended Concurrency
|
||||
|
||||
### Python SDK usage
|
||||
- DS2API computes recommended concurrency dynamically as: `account_count * per_account_inflight_limit`
|
||||
- Default per-account inflight limit is `2`, so default recommendation is `account_count * 2`
|
||||
- You can override per-account inflight via `DS2API_ACCOUNT_MAX_INFLIGHT` (or `DS2API_ACCOUNT_CONCURRENCY`)
|
||||
- `GET /admin/queue/status` returns both `max_inflight_per_account` and `recommended_concurrency`
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
## Tool Call Adaptation
|
||||
|
||||
client = OpenAI(
|
||||
api_key="your-api-key",
|
||||
base_url="http://localhost:5001/v1"
|
||||
)
|
||||
Tool-call leakage is handled in the current implementation:
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-reasoner",
|
||||
messages=[{"role": "user", "content": "Explain quantum entanglement"}],
|
||||
stream=True
|
||||
)
|
||||
- With `tools` + `stream=true`, DS2API buffers text deltas first
|
||||
- If a tool call is detected, DS2API returns structured `tool_calls` only
|
||||
- If no tool call is detected, DS2API emits the buffered text once
|
||||
- Parser supports mixed text, fenced JSON, and `function.arguments` payloads
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
## Docs and Testing
|
||||
|
||||
## 🔧 Deployment Notes
|
||||
|
||||
### Nginx reverse proxy
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Docker deployment
|
||||
- API docs: `API.md` / `API.en.md`
|
||||
- Deployment docs: `DEPLOY.md` / `DEPLOY.en.md`
|
||||
- Contributing: `CONTRIBUTING.md` / `CONTRIBUTING.en.md`
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo and enter the directory
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Configure environment variables
|
||||
cp .env.example .env
|
||||
# Edit .env and fill in DS2API_ADMIN_KEY and DS2API_CONFIG_JSON
|
||||
|
||||
# 3. Start the service
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Check logs
|
||||
docker-compose logs -f
|
||||
go test ./...
|
||||
```
|
||||
|
||||
> **Docker advantage**: Zero-intrusion design; update the main code with `docker-compose up -d --build` without changing Docker configuration. See [DEPLOY.md](DEPLOY.md#docker-deployment-recommended).
|
||||
## Disclaimer
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
**This project is based on reverse engineering and stability is not guaranteed.**
|
||||
|
||||
- For learning and research only. **No commercial use or public service is allowed.**
|
||||
- For production, use the official [DeepSeek API](https://platform.deepseek.com/)
|
||||
- You assume all risks from using this project
|
||||
|
||||
## 📜 Acknowledgements
|
||||
|
||||
This project is based on the following open-source projects:
|
||||
|
||||
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
|
||||
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
|
||||
|
||||
## 📊 Star History
|
||||
|
||||
[](https://star-history.com/#CJackHwang/ds2api&Date)
|
||||
This project is built through reverse engineering and is provided for learning and research only. Stability is not guaranteed. Do not use it in scenarios that violate terms of service or laws.
|
||||
|
||||
20
api/index.go
Normal file
20
api/index.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"ds2api/internal/server"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
app *server.App
|
||||
)
|
||||
|
||||
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
once.Do(func() {
|
||||
app = server.NewApp()
|
||||
})
|
||||
app.Router.ServeHTTP(w, r)
|
||||
}
|
||||
69
app.py
69
app.py
@@ -1,69 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API - DeepSeek to OpenAI API 转换服务
|
||||
|
||||
支持:
|
||||
- OpenAI 兼容接口: /v1/chat/completions, /v1/models
|
||||
- Claude 兼容接口: /anthropic/v1/messages, /anthropic/v1/models
|
||||
|
||||
使用方法:
|
||||
本地开发: python dev.py
|
||||
生产环境: uvicorn app:app --host 0.0.0.0 --port 5001
|
||||
Vercel: 自动部署
|
||||
"""
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from core.config import IS_VERCEL, logger
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="DS2API",
|
||||
description="DeepSeek to OpenAI/Claude API",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
# 全局异常处理
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||
logger.exception(f"[unhandled_exception] {request.method} {request.url.path}: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"type": "api_error", "message": "Internal Server Error"}},
|
||||
)
|
||||
|
||||
|
||||
# CORS 中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS", "PUT", "DELETE"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
from routes.openai import router as openai_router
|
||||
from routes.claude import router as claude_router
|
||||
from routes.home import router as home_router
|
||||
from routes.admin import router as admin_router
|
||||
|
||||
app.include_router(openai_router)
|
||||
app.include_router(claude_router)
|
||||
# admin_router 必须在 home_router 之前,否则 home.py 的 /admin/{path:path} 会拦截 admin API
|
||||
app.include_router(admin_router)
|
||||
app.include_router(home_router)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 本地运行入口
|
||||
# ----------------------------------------------------------------------
|
||||
if __name__ == "__main__" and not IS_VERCEL:
|
||||
import uvicorn
|
||||
|
||||
port = int(os.getenv("PORT", "5001"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
23
cmd/ds2api/main.go
Normal file
23
cmd/ds2api/main.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := server.NewApp()
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = "5001"
|
||||
}
|
||||
config.Logger.Info("starting ds2api", "port", port)
|
||||
if err := http.ListenAndServe("0.0.0.0:"+port, app.Router); err != nil {
|
||||
config.Logger.Error("server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# DS2API Core Modules
|
||||
247
core/auth.py
247
core/auth.py
@@ -1,247 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""账号认证与管理模块 - 轮询(Round-Robin)策略"""
|
||||
import threading
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .config import CONFIG, logger
|
||||
from .deepseek import login_deepseek_via_account, BASE_HEADERS
|
||||
from .utils import get_account_identifier
|
||||
|
||||
# -------------------------- 全局账号队列 --------------------------
|
||||
# 使用列表实现轮询队列,配合线程锁保证并发安全
|
||||
account_queue = [] # 可用账号队列
|
||||
in_use_accounts = {} # 正在使用的账号 {account_id: account}
|
||||
_queue_lock = threading.Lock() # 线程锁
|
||||
|
||||
claude_api_key_queue = [] # 维护所有可用的Claude API keys
|
||||
|
||||
|
||||
def init_account_queue():
|
||||
"""初始化时从配置加载账号(不再随机排序,保持配置顺序)"""
|
||||
global account_queue, in_use_accounts
|
||||
with _queue_lock:
|
||||
account_queue = CONFIG.get("accounts", [])[:] # 深拷贝
|
||||
in_use_accounts = {}
|
||||
# 按 token 有无排序:有 token 的账号优先
|
||||
account_queue.sort(key=lambda a: 0 if a.get("token", "").strip() else 1)
|
||||
logger.info(f"[init_account_queue] 初始化 {len(account_queue)} 个账号,轮询模式")
|
||||
|
||||
|
||||
def init_claude_api_key_queue():
|
||||
"""Claude API keys由用户自己的token提供,这里初始化为空"""
|
||||
global claude_api_key_queue
|
||||
claude_api_key_queue = []
|
||||
|
||||
|
||||
# 初始化
|
||||
init_account_queue()
|
||||
init_claude_api_key_queue()
|
||||
|
||||
|
||||
# get_account_identifier 已移至 core.utils
|
||||
|
||||
|
||||
def get_queue_status() -> dict:
|
||||
"""获取账号队列状态(用于监控)"""
|
||||
with _queue_lock:
|
||||
# total 应该是配置中的账号总数,而非队列相加(避免状态不一致导致重复计数)
|
||||
total_accounts = len(CONFIG.get("accounts", []))
|
||||
return {
|
||||
"available": len(account_queue),
|
||||
"in_use": len(in_use_accounts),
|
||||
"total": total_accounts,
|
||||
"available_accounts": [get_account_identifier(a) for a in account_queue],
|
||||
"in_use_accounts": list(in_use_accounts.keys()),
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 账号选择与释放 - 轮询(Round-Robin)策略
|
||||
# ----------------------------------------------------------------------
|
||||
def choose_new_account(exclude_ids=None, target_id=None):
|
||||
"""轮询选择策略:
|
||||
1. 使用线程锁保证并发安全
|
||||
2. 如果指定了 target_id,优先尝试获取该账号
|
||||
3. 优先选择队首的有 token 账号
|
||||
4. 从队列头部取出账号(FIFO)
|
||||
5. 请求完成后调用 release_account 将账号放回队尾
|
||||
"""
|
||||
if exclude_ids is None:
|
||||
exclude_ids = []
|
||||
|
||||
with _queue_lock:
|
||||
# 0. 如果指定了目标账号,优先尝试获取
|
||||
if target_id:
|
||||
for i in range(len(account_queue)):
|
||||
acc = account_queue[i]
|
||||
acc_id = get_account_identifier(acc)
|
||||
if acc_id == target_id:
|
||||
selected = account_queue.pop(i)
|
||||
in_use_accounts[acc_id] = selected
|
||||
logger.info(f"[choose_new_account] 指定选择: {acc_id} | 队列剩余: {len(account_queue)}")
|
||||
return selected
|
||||
# 如果队列中没找到,且不在 in_use 中,说明账号不存在
|
||||
if target_id not in in_use_accounts:
|
||||
logger.warning(f"[choose_new_account] 指定账号不存在: {target_id}")
|
||||
else:
|
||||
logger.warning(f"[choose_new_account] 指定账号正忙: {target_id}")
|
||||
return None
|
||||
|
||||
# 第一轮:优先选择已有 token 的账号
|
||||
for i in range(len(account_queue)):
|
||||
acc = account_queue[i]
|
||||
acc_id = get_account_identifier(acc)
|
||||
if acc_id and acc_id not in exclude_ids:
|
||||
if acc.get("token", "").strip(): # 已有 token
|
||||
selected = account_queue.pop(i)
|
||||
in_use_accounts[acc_id] = selected
|
||||
logger.info(f"[choose_new_account] 轮询选择(有token): {acc_id} | 队列剩余: {len(account_queue)}")
|
||||
return selected
|
||||
|
||||
# 第二轮:选择任意账号(需要登录)
|
||||
for i in range(len(account_queue)):
|
||||
acc = account_queue[i]
|
||||
acc_id = get_account_identifier(acc)
|
||||
if acc_id and acc_id not in exclude_ids:
|
||||
selected = account_queue.pop(i)
|
||||
in_use_accounts[acc_id] = selected
|
||||
logger.info(f"[choose_new_account] 轮询选择(需登录): {acc_id} | 队列剩余: {len(account_queue)}")
|
||||
return selected
|
||||
|
||||
logger.warning(f"[choose_new_account] 没有可用账号 | 队列: {len(account_queue)}, 使用中: {len(in_use_accounts)}")
|
||||
return None
|
||||
|
||||
|
||||
def release_account(account: dict):
|
||||
"""将账号重新加入队列末尾(轮询核心:用完放队尾)"""
|
||||
if not account:
|
||||
return
|
||||
|
||||
acc_id = get_account_identifier(account)
|
||||
with _queue_lock:
|
||||
# 从使用中移除
|
||||
if acc_id in in_use_accounts:
|
||||
del in_use_accounts[acc_id]
|
||||
# 放回队尾
|
||||
account_queue.append(account)
|
||||
logger.debug(f"[release_account] 释放账号: {acc_id} | 队列长度: {len(account_queue)}")
|
||||
else:
|
||||
logger.warning(f"[release_account] 账号 {acc_id} 不在使用列表中 (可能是因为重置了队列),跳过释放")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Claude API key 管理函数(简化版本)
|
||||
# ----------------------------------------------------------------------
|
||||
def choose_claude_api_key():
|
||||
"""选择一个可用的Claude API key - 现在直接由用户提供"""
|
||||
return None
|
||||
|
||||
|
||||
def release_claude_api_key(api_key):
|
||||
"""释放Claude API key - 现在无需操作"""
|
||||
pass
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 判断调用模式:配置模式 vs 用户自带 token
|
||||
# ----------------------------------------------------------------------
|
||||
def determine_mode_and_token(request: Request):
|
||||
"""
|
||||
根据请求头 Authorization 判断使用哪种模式:
|
||||
- 如果 Bearer token 出现在 CONFIG["keys"] 中,则为配置模式,从 CONFIG["accounts"] 中随机选择一个账号(排除已尝试账号),
|
||||
检查该账号是否已有 token,否则调用登录接口获取;
|
||||
- 否则,直接使用请求中的 Bearer 值作为 DeepSeek token。
|
||||
结果存入 request.state.deepseek_token;配置模式下同时存入 request.state.account 与 request.state.tried_accounts。
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Unauthorized: missing Bearer token."
|
||||
)
|
||||
caller_key = auth_header.replace("Bearer ", "", 1).strip()
|
||||
config_keys = CONFIG.get("keys", [])
|
||||
if caller_key in config_keys:
|
||||
request.state.use_config_token = True
|
||||
request.state.tried_accounts = [] # 初始化已尝试账号
|
||||
|
||||
target_account = request.headers.get("X-Ds2-Target-Account")
|
||||
selected_account = choose_new_account(target_id=target_account)
|
||||
|
||||
if not selected_account:
|
||||
detail_msg = "No accounts configured or all accounts are busy."
|
||||
if target_account:
|
||||
detail_msg = f"Target account {target_account} is busy or not found."
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=detail_msg,
|
||||
)
|
||||
if not selected_account.get("token", "").strip():
|
||||
try:
|
||||
login_deepseek_via_account(selected_account)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[determine_mode_and_token] 账号 {get_account_identifier(selected_account)} 登录失败:{e}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Account login failed.")
|
||||
|
||||
request.state.deepseek_token = selected_account.get("token")
|
||||
request.state.account = selected_account
|
||||
|
||||
else:
|
||||
request.state.use_config_token = False
|
||||
request.state.deepseek_token = caller_key
|
||||
|
||||
|
||||
def get_auth_headers(request: Request) -> dict:
|
||||
"""返回 DeepSeek 请求所需的公共请求头"""
|
||||
return {**BASE_HEADERS, "authorization": f"Bearer {request.state.deepseek_token}"}
|
||||
|
||||
|
||||
# determine_claude_mode_and_token 已移除(直接使用 determine_mode_and_token)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Token 刷新机制
|
||||
# ----------------------------------------------------------------------
|
||||
def refresh_account_token(request: Request) -> bool:
|
||||
"""当 token 过期时,刷新账号 token。
|
||||
|
||||
返回 True 表示刷新成功,False 表示刷新失败。
|
||||
调用后 request.state.deepseek_token 会被更新。
|
||||
"""
|
||||
if not getattr(request.state, 'use_config_token', False):
|
||||
# 用户自带 token,无法刷新
|
||||
return False
|
||||
|
||||
account = getattr(request.state, 'account', None)
|
||||
if not account:
|
||||
return False
|
||||
|
||||
acc_id = get_account_identifier(account)
|
||||
logger.info(f"[refresh_account_token] 尝试刷新账号 {acc_id} 的 token")
|
||||
|
||||
try:
|
||||
# 清除旧 token
|
||||
account["token"] = ""
|
||||
# 重新登录
|
||||
login_deepseek_via_account(account)
|
||||
# 更新 request 状态
|
||||
request.state.deepseek_token = account.get("token")
|
||||
logger.info(f"[refresh_account_token] 账号 {acc_id} token 刷新成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[refresh_account_token] 账号 {acc_id} token 刷新失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def mark_token_invalid(request: Request):
|
||||
"""标记当前账号的 token 为无效,清除它以便下次重新登录"""
|
||||
if not getattr(request.state, 'use_config_token', False):
|
||||
return
|
||||
|
||||
account = getattr(request.state, 'account', None)
|
||||
if account:
|
||||
acc_id = get_account_identifier(account)
|
||||
logger.warning(f"[mark_token_invalid] 标记账号 {acc_id} 的 token 为无效")
|
||||
account["token"] = ""
|
||||
|
||||
111
core/config.py
111
core/config.py
@@ -1,111 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置管理模块"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import transformers
|
||||
|
||||
# -------------------------- 获取项目根目录 --------------------------
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
IS_VERCEL = bool(os.getenv("VERCEL")) or bool(os.getenv("NOW_REGION"))
|
||||
|
||||
|
||||
def resolve_path(env_key: str, default_rel: str) -> str:
|
||||
"""解析路径,支持环境变量覆盖"""
|
||||
raw = os.getenv(env_key)
|
||||
if raw:
|
||||
return raw if os.path.isabs(raw) else os.path.join(BASE_DIR, raw)
|
||||
return os.path.join(BASE_DIR, default_rel)
|
||||
|
||||
|
||||
# -------------------------- 日志配置 --------------------------
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
force=True,
|
||||
)
|
||||
logger = logging.getLogger("ds2api")
|
||||
|
||||
# -------------------------- 初始化 tokenizer --------------------------
|
||||
chat_tokenizer_dir = resolve_path("DS2API_TOKENIZER_DIR", "")
|
||||
# 抑制 Mistral tokenizer regex 警告(不影响 DeepSeek tokenization)
|
||||
_tf_logger = logging.getLogger("transformers")
|
||||
_tf_log_level = _tf_logger.level
|
||||
_tf_logger.setLevel(logging.ERROR)
|
||||
tokenizer = transformers.AutoTokenizer.from_pretrained(
|
||||
chat_tokenizer_dir, trust_remote_code=True
|
||||
)
|
||||
_tf_logger.setLevel(_tf_log_level)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 配置文件的读写函数
|
||||
# ----------------------------------------------------------------------
|
||||
CONFIG_PATH = resolve_path("DS2API_CONFIG_PATH", "config.json")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""加载配置。
|
||||
|
||||
优先从环境变量读取:
|
||||
- DS2API_CONFIG_JSON / CONFIG_JSON: 直接 JSON 字符串,或 base64 编码后的 JSON
|
||||
|
||||
若未提供环境变量,再从 CONFIG_PATH 指向的文件读取。
|
||||
"""
|
||||
raw_cfg = os.getenv("DS2API_CONFIG_JSON") or os.getenv("CONFIG_JSON")
|
||||
if raw_cfg:
|
||||
try:
|
||||
return json.loads(raw_cfg)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
decoded = base64.b64decode(raw_cfg).decode("utf-8")
|
||||
return json.loads(decoded)
|
||||
except Exception as e:
|
||||
logger.warning(f"[load_config] 环境变量配置解析失败: {e}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"[load_config] 无法读取配置文件({CONFIG_PATH}): {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(cfg: dict) -> None:
|
||||
"""将配置写回 config.json。
|
||||
|
||||
Vercel 环境文件系统通常是只读的;且如果配置来自环境变量,也无法回写。
|
||||
所以这里失败不应影响主流程。
|
||||
"""
|
||||
if os.getenv("DS2API_CONFIG_JSON") or os.getenv("CONFIG_JSON"):
|
||||
logger.info("[save_config] 配置来自环境变量,跳过写回")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
except PermissionError as e:
|
||||
logger.warning(f"[save_config] 配置文件不可写({CONFIG_PATH}): {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"[save_config] 写入 config.json 失败: {e}")
|
||||
|
||||
|
||||
# 全局配置
|
||||
CONFIG = load_config()
|
||||
if not CONFIG:
|
||||
logger.warning(
|
||||
"[config] 未加载到有效配置,请提供 config.json(路径可用 DS2API_CONFIG_PATH 指定)或设置环境变量 DS2API_CONFIG_JSON"
|
||||
)
|
||||
|
||||
# WASM 模块文件路径
|
||||
WASM_PATH = resolve_path("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm")
|
||||
|
||||
# 模板目录
|
||||
TEMPLATES_DIR = resolve_path("DS2API_TEMPLATES_DIR", "templates")
|
||||
|
||||
# WebUI 静态文件目录
|
||||
STATIC_ADMIN_DIR = resolve_path("DS2API_STATIC_ADMIN_DIR", "static/admin")
|
||||
@@ -1,43 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""常量定义模块 - 统一管理项目中的所有常量"""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 网络和超时配置
|
||||
# ----------------------------------------------------------------------
|
||||
KEEP_ALIVE_TIMEOUT = 5 # 保活超时(秒)
|
||||
STREAM_IDLE_TIMEOUT = 30 # 流无新内容超时(秒)
|
||||
MAX_KEEPALIVE_COUNT = 10 # 最大连续 keepalive 次数
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# DeepSeek API 配置
|
||||
# ----------------------------------------------------------------------
|
||||
DEEPSEEK_HOST = "chat.deepseek.com"
|
||||
DEEPSEEK_LOGIN_URL = f"https://{DEEPSEEK_HOST}/api/v0/users/login"
|
||||
DEEPSEEK_CREATE_SESSION_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat_session/create"
|
||||
DEEPSEEK_CREATE_POW_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat/create_pow_challenge"
|
||||
DEEPSEEK_COMPLETION_URL = f"https://{DEEPSEEK_HOST}/api/v0/chat/completion"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 请求头配置
|
||||
# ----------------------------------------------------------------------
|
||||
BASE_HEADERS = {
|
||||
"Host": "chat.deepseek.com",
|
||||
"User-Agent": "DeepSeek/1.6.11 Android/35",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Content-Type": "application/json",
|
||||
"x-client-platform": "android",
|
||||
"x-client-version": "1.6.11",
|
||||
"x-client-locale": "zh_CN",
|
||||
"accept-charset": "UTF-8",
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# SSE 解析配置
|
||||
# ----------------------------------------------------------------------
|
||||
# 跳过的路径模式(状态相关,不是内容)
|
||||
SKIP_PATTERNS = [
|
||||
"quasi_status", "elapsed_secs", "token_usage",
|
||||
"pending_fragment", "conversation_mode",
|
||||
"fragments/-1/status", "fragments/-2/status", "fragments/-3/status"
|
||||
]
|
||||
138
core/deepseek.py
138
core/deepseek.py
@@ -1,138 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DeepSeek API 相关逻辑"""
|
||||
import time
|
||||
from curl_cffi import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .config import CONFIG, save_config, logger
|
||||
from .utils import get_account_identifier
|
||||
from .constants import (
|
||||
DEEPSEEK_HOST,
|
||||
DEEPSEEK_LOGIN_URL,
|
||||
DEEPSEEK_CREATE_SESSION_URL,
|
||||
DEEPSEEK_CREATE_POW_URL,
|
||||
DEEPSEEK_COMPLETION_URL,
|
||||
BASE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
# get_account_identifier 已移至 core.utils
|
||||
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 登录函数:支持使用 email 或 mobile 登录
|
||||
# ----------------------------------------------------------------------
|
||||
def login_deepseek_via_account(account: dict) -> str:
|
||||
"""使用 account 中的 email 或 mobile 登录 DeepSeek,
|
||||
成功后将返回的 token 写入 account 并保存至配置文件,返回新 token。
|
||||
"""
|
||||
email = account.get("email", "").strip()
|
||||
mobile = account.get("mobile", "").strip()
|
||||
password = account.get("password", "").strip()
|
||||
if not password or (not email and not mobile):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="账号缺少必要的登录信息(必须提供 email 或 mobile 以及 password)",
|
||||
)
|
||||
if email:
|
||||
payload = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"device_id": "deepseek_to_api",
|
||||
"os": "android",
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"mobile": mobile,
|
||||
"area_code": None,
|
||||
"password": password,
|
||||
"device_id": "deepseek_to_api",
|
||||
"os": "android",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(
|
||||
DEEPSEEK_LOGIN_URL, headers=BASE_HEADERS, json=payload, impersonate="safari15_3"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(f"[login_deepseek_via_account] 登录请求异常: {e}")
|
||||
raise HTTPException(status_code=500, detail="Account login failed: 请求异常")
|
||||
try:
|
||||
logger.warning(f"[login_deepseek_via_account] {resp.text}")
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"[login_deepseek_via_account] JSON解析失败: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Account login failed: invalid JSON response"
|
||||
)
|
||||
|
||||
# 检查 API 错误码
|
||||
if data.get("code") != 0:
|
||||
error_msg = data.get("msg", "Unknown error")
|
||||
logger.error(f"[login_deepseek_via_account] API错误: {error_msg}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Account login failed: {error_msg}"
|
||||
)
|
||||
|
||||
# 检查业务错误码
|
||||
biz_code = data.get("data", {}).get("biz_code")
|
||||
biz_msg = data.get("data", {}).get("biz_msg", "")
|
||||
if biz_code != 0:
|
||||
logger.error(f"[login_deepseek_via_account] 业务错误: {biz_msg}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Account login failed: {biz_msg}"
|
||||
)
|
||||
|
||||
# 校验响应数据格式是否正确
|
||||
if (
|
||||
data.get("data") is None
|
||||
or data["data"].get("biz_data") is None
|
||||
or data["data"]["biz_data"].get("user") is None
|
||||
):
|
||||
logger.error(f"[login_deepseek_via_account] 登录响应格式错误: {data}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Account login failed: invalid response format"
|
||||
)
|
||||
new_token = data["data"]["biz_data"]["user"].get("token")
|
||||
if not new_token:
|
||||
logger.error(f"[login_deepseek_via_account] 登录响应中缺少 token: {data}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Account login failed: missing token"
|
||||
)
|
||||
account["token"] = new_token
|
||||
save_config(CONFIG)
|
||||
return new_token
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 封装对话接口调用的重试机制
|
||||
# ----------------------------------------------------------------------
|
||||
def call_completion_endpoint(payload: dict, headers: dict, max_attempts: int = 3):
|
||||
"""调用 DeepSeek 对话接口,支持重试"""
|
||||
attempts = 0
|
||||
while attempts < max_attempts:
|
||||
try:
|
||||
deepseek_resp = requests.post(
|
||||
DEEPSEEK_COMPLETION_URL,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
stream=True,
|
||||
impersonate="safari15_3",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[call_completion_endpoint] 请求异常: {e}")
|
||||
time.sleep(1)
|
||||
attempts += 1
|
||||
continue
|
||||
if deepseek_resp.status_code == 200:
|
||||
return deepseek_resp
|
||||
else:
|
||||
logger.warning(
|
||||
f"[call_completion_endpoint] 调用对话接口失败, 状态码: {deepseek_resp.status_code}"
|
||||
)
|
||||
deepseek_resp.close()
|
||||
time.sleep(1)
|
||||
attempts += 1
|
||||
return None
|
||||
118
core/messages.py
118
core/messages.py
@@ -1,118 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""消息处理模块"""
|
||||
import re
|
||||
|
||||
from .config import CONFIG, logger
|
||||
|
||||
# Claude 默认模型
|
||||
CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
||||
|
||||
# 预编译正则表达式(性能优化)
|
||||
_MARKDOWN_IMAGE_PATTERN = re.compile(r"!\[(.*?)\]\((.*?)\)")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 消息预处理函数,将多轮对话合并成最终 prompt
|
||||
# ----------------------------------------------------------------------
|
||||
def messages_prepare(messages: list) -> str:
|
||||
"""处理消息列表,合并连续相同角色的消息,并添加角色标签:
|
||||
- 对于 assistant 消息,加上 <|Assistant|> 前缀及 <|end▁of▁sentence|> 结束标签;
|
||||
- 对于 user/system 消息(除第一条外)加上 <|User|> 前缀;
|
||||
- 如果消息 content 为数组,则提取其中 type 为 "text" 的部分;
|
||||
- 最后移除 markdown 图片格式的内容。
|
||||
"""
|
||||
processed = []
|
||||
for m in messages:
|
||||
role = m.get("role", "")
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
texts = [
|
||||
item.get("text", "") for item in content if item.get("type") == "text"
|
||||
]
|
||||
text = "\n".join(texts)
|
||||
else:
|
||||
text = str(content)
|
||||
processed.append({"role": role, "text": text})
|
||||
if not processed:
|
||||
return ""
|
||||
# 合并连续同一角色的消息
|
||||
merged = [processed[0]]
|
||||
for msg in processed[1:]:
|
||||
if msg["role"] == merged[-1]["role"]:
|
||||
merged[-1]["text"] += "\n\n" + msg["text"]
|
||||
else:
|
||||
merged.append(msg)
|
||||
# 添加标签
|
||||
parts = []
|
||||
for idx, block in enumerate(merged):
|
||||
role = block["role"]
|
||||
text = block["text"]
|
||||
if role == "assistant":
|
||||
parts.append(f"<|Assistant|>{text}<|end▁of▁sentence|>")
|
||||
elif role in ("user", "system"):
|
||||
if idx > 0:
|
||||
parts.append(f"<|User|>{text}")
|
||||
else:
|
||||
parts.append(text)
|
||||
else:
|
||||
parts.append(text)
|
||||
final_prompt = "".join(parts)
|
||||
# 仅移除 markdown 图片格式(不全部移除 !)- 使用预编译的正则表达式
|
||||
final_prompt = _MARKDOWN_IMAGE_PATTERN.sub(r"[\1](\2)", final_prompt)
|
||||
return final_prompt
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# OpenAI到Claude格式转换函数
|
||||
# ----------------------------------------------------------------------
|
||||
def convert_claude_to_deepseek(claude_request: dict) -> dict:
|
||||
"""将Claude格式的请求转换为DeepSeek格式(基于现有OpenAI接口)"""
|
||||
messages = claude_request.get("messages", [])
|
||||
model = claude_request.get("model", CLAUDE_DEFAULT_MODEL)
|
||||
|
||||
# 从配置文件读取Claude模型映射
|
||||
claude_mapping = CONFIG.get(
|
||||
"claude_model_mapping", {"fast": "deepseek-chat", "slow": "deepseek-chat"}
|
||||
)
|
||||
|
||||
# Claude模型映射到DeepSeek模型 - 基于配置和模型特征判断
|
||||
if (
|
||||
"opus" in model.lower()
|
||||
or "reasoner" in model.lower()
|
||||
or "slow" in model.lower()
|
||||
):
|
||||
deepseek_model = claude_mapping.get("slow", "deepseek-chat")
|
||||
else:
|
||||
deepseek_model = claude_mapping.get("fast", "deepseek-chat")
|
||||
|
||||
deepseek_request = {"model": deepseek_model, "messages": messages.copy()}
|
||||
|
||||
# 处理system消息 - 将system参数转换为system role消息
|
||||
if "system" in claude_request:
|
||||
system_msg = {"role": "system", "content": claude_request["system"]}
|
||||
deepseek_request["messages"].insert(0, system_msg)
|
||||
|
||||
# 添加可选参数
|
||||
if "temperature" in claude_request:
|
||||
deepseek_request["temperature"] = claude_request["temperature"]
|
||||
if "top_p" in claude_request:
|
||||
deepseek_request["top_p"] = claude_request["top_p"]
|
||||
if "stop_sequences" in claude_request:
|
||||
deepseek_request["stop"] = claude_request["stop_sequences"]
|
||||
if "stream" in claude_request:
|
||||
deepseek_request["stream"] = claude_request["stream"]
|
||||
|
||||
return deepseek_request
|
||||
|
||||
|
||||
def convert_deepseek_to_claude_format(
|
||||
deepseek_response: dict, original_claude_model: str = CLAUDE_DEFAULT_MODEL
|
||||
) -> dict:
|
||||
"""将DeepSeek响应转换为Claude格式的OpenAI响应"""
|
||||
# DeepSeek响应已经是OpenAI格式,只需要修改模型名称
|
||||
if isinstance(deepseek_response, dict):
|
||||
claude_response = deepseek_response.copy()
|
||||
claude_response["model"] = original_claude_model
|
||||
return claude_response
|
||||
|
||||
return deepseek_response
|
||||
@@ -1,90 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""模型定义模块 - 集中管理所有支持的模型"""
|
||||
|
||||
# DeepSeek 模型列表(官方模型名称)
|
||||
DEEPSEEK_MODELS = [
|
||||
{
|
||||
"id": "deepseek-chat",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "deepseek",
|
||||
"permission": [],
|
||||
},
|
||||
{
|
||||
"id": "deepseek-reasoner",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "deepseek",
|
||||
"permission": [],
|
||||
},
|
||||
{
|
||||
"id": "deepseek-chat-search",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "deepseek",
|
||||
"permission": [],
|
||||
},
|
||||
{
|
||||
"id": "deepseek-reasoner-search",
|
||||
"object": "model",
|
||||
"created": 1677610602,
|
||||
"owned_by": "deepseek",
|
||||
"permission": [],
|
||||
},
|
||||
]
|
||||
|
||||
# Claude 模型映射列表
|
||||
CLAUDE_MODELS = [
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514",
|
||||
"object": "model",
|
||||
"created": 1715635200,
|
||||
"owned_by": "anthropic",
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514-fast",
|
||||
"object": "model",
|
||||
"created": 1715635200,
|
||||
"owned_by": "anthropic",
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-20250514-slow",
|
||||
"object": "model",
|
||||
"created": 1715635200,
|
||||
"owned_by": "anthropic",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_model_config(model: str) -> tuple[bool, bool]:
|
||||
"""根据模型名称获取配置
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
|
||||
Returns:
|
||||
(thinking_enabled, search_enabled) 元组
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
|
||||
if model_lower == "deepseek-chat":
|
||||
return False, False
|
||||
elif model_lower == "deepseek-reasoner":
|
||||
return True, False
|
||||
elif model_lower == "deepseek-chat-search":
|
||||
return False, True
|
||||
elif model_lower == "deepseek-reasoner-search":
|
||||
return True, True
|
||||
else:
|
||||
return None, None # 不支持的模型
|
||||
|
||||
|
||||
def get_openai_models_response() -> dict:
|
||||
"""获取 OpenAI 格式的模型列表响应"""
|
||||
return {"object": "list", "data": DEEPSEEK_MODELS}
|
||||
|
||||
|
||||
def get_claude_models_response() -> dict:
|
||||
"""获取 Claude 格式的模型列表响应"""
|
||||
return {"object": "list", "data": CLAUDE_MODELS}
|
||||
|
||||
253
core/pow.py
253
core/pow.py
@@ -1,253 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PoW (Proof of Work) 计算模块"""
|
||||
import base64
|
||||
import ctypes
|
||||
import json
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
from curl_cffi import requests
|
||||
from wasmtime import Engine, Linker, Module, Store
|
||||
|
||||
from .config import CONFIG, WASM_PATH, logger
|
||||
from .utils import get_account_identifier
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# WASM 模块缓存 - 避免每次请求都重新加载
|
||||
# ----------------------------------------------------------------------
|
||||
_wasm_cache_lock = threading.Lock()
|
||||
_wasm_engine = None
|
||||
_wasm_module = None
|
||||
|
||||
|
||||
def _get_cached_wasm_module(wasm_path: str):
|
||||
"""获取缓存的 WASM 模块,首次调用时加载"""
|
||||
global _wasm_engine, _wasm_module
|
||||
|
||||
if _wasm_module is not None:
|
||||
return _wasm_engine, _wasm_module
|
||||
|
||||
with _wasm_cache_lock:
|
||||
# 双重检查锁定
|
||||
if _wasm_module is not None:
|
||||
return _wasm_engine, _wasm_module
|
||||
|
||||
try:
|
||||
with open(wasm_path, "rb") as f:
|
||||
wasm_bytes = f.read()
|
||||
_wasm_engine = Engine()
|
||||
_wasm_module = Module(_wasm_engine, wasm_bytes)
|
||||
logger.info(f"[WASM] 已缓存 WASM 模块: {wasm_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[WASM] 加载 WASM 模块失败: {e}")
|
||||
raise RuntimeError(f"加载 wasm 文件失败: {wasm_path}, 错误: {e}")
|
||||
|
||||
return _wasm_engine, _wasm_module
|
||||
|
||||
|
||||
# 启动时预加载 WASM 模块
|
||||
try:
|
||||
_get_cached_wasm_module(WASM_PATH)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WASM] 启动时预加载失败(将在首次使用时重试): {e}")
|
||||
|
||||
# get_account_identifier 已移至 core.utils
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 使用 WASM 模块计算 PoW 答案的辅助函数
|
||||
# ----------------------------------------------------------------------
|
||||
def compute_pow_answer(
|
||||
algorithm: str,
|
||||
challenge_str: str,
|
||||
salt: str,
|
||||
difficulty: int,
|
||||
expire_at: int,
|
||||
signature: str,
|
||||
target_path: str,
|
||||
wasm_path: str,
|
||||
) -> int:
|
||||
"""
|
||||
使用 WASM 模块计算 DeepSeekHash 答案(answer)。
|
||||
根据 JS 逻辑:
|
||||
- 拼接前缀: "{salt}_{expire_at}_"
|
||||
- 将 challenge 与前缀写入 wasm 内存后调用 wasm_solve 进行求解,
|
||||
- 从 wasm 内存中读取状态与求解结果,
|
||||
- 若状态非 0,则返回整数形式的答案,否则返回 None。
|
||||
|
||||
优化:使用缓存的 WASM 模块,避免每次请求都重新加载文件。
|
||||
"""
|
||||
if algorithm != "DeepSeekHashV1":
|
||||
raise ValueError(f"不支持的算法:{algorithm}")
|
||||
|
||||
prefix = f"{salt}_{expire_at}_"
|
||||
|
||||
# 获取缓存的 WASM 模块(避免重复加载文件)
|
||||
engine, module = _get_cached_wasm_module(wasm_path)
|
||||
|
||||
# 每次调用创建新的 Store 和实例(必须的,因为 Store 不是线程安全的)
|
||||
store = Store(engine)
|
||||
linker = Linker(engine)
|
||||
instance = linker.instantiate(store, module)
|
||||
exports = instance.exports(store)
|
||||
|
||||
try:
|
||||
memory = exports["memory"]
|
||||
add_to_stack = exports["__wbindgen_add_to_stack_pointer"]
|
||||
alloc = exports["__wbindgen_export_0"]
|
||||
wasm_solve = exports["wasm_solve"]
|
||||
except KeyError as e:
|
||||
raise RuntimeError(f"缺少 wasm 导出函数: {e}")
|
||||
|
||||
def write_memory(offset: int, data: bytes):
|
||||
size = len(data)
|
||||
base_addr = ctypes.cast(memory.data_ptr(store), ctypes.c_void_p).value
|
||||
ctypes.memmove(base_addr + offset, data, size)
|
||||
|
||||
def read_memory(offset: int, size: int) -> bytes:
|
||||
base_addr = ctypes.cast(memory.data_ptr(store), ctypes.c_void_p).value
|
||||
return ctypes.string_at(base_addr + offset, size)
|
||||
|
||||
def encode_string(text: str):
|
||||
data = text.encode("utf-8")
|
||||
length = len(data)
|
||||
ptr_val = alloc(store, length, 1)
|
||||
ptr = int(ptr_val.value) if hasattr(ptr_val, "value") else int(ptr_val)
|
||||
write_memory(ptr, data)
|
||||
return ptr, length
|
||||
|
||||
# 1. 申请 16 字节栈空间
|
||||
retptr = add_to_stack(store, -16)
|
||||
# 2. 编码 challenge 与 prefix 到 wasm 内存中
|
||||
ptr_challenge, len_challenge = encode_string(challenge_str)
|
||||
ptr_prefix, len_prefix = encode_string(prefix)
|
||||
# 3. 调用 wasm_solve(注意:difficulty 以 float 形式传入)
|
||||
wasm_solve(
|
||||
store,
|
||||
retptr,
|
||||
ptr_challenge,
|
||||
len_challenge,
|
||||
ptr_prefix,
|
||||
len_prefix,
|
||||
float(difficulty),
|
||||
)
|
||||
# 4. 从 retptr 处读取 4 字节状态和 8 字节求解结果
|
||||
status_bytes = read_memory(retptr, 4)
|
||||
if len(status_bytes) != 4:
|
||||
add_to_stack(store, 16)
|
||||
raise RuntimeError("读取状态字节失败")
|
||||
status = struct.unpack("<i", status_bytes)[0]
|
||||
value_bytes = read_memory(retptr + 8, 8)
|
||||
if len(value_bytes) != 8:
|
||||
add_to_stack(store, 16)
|
||||
raise RuntimeError("读取结果字节失败")
|
||||
value = struct.unpack("<d", value_bytes)[0]
|
||||
# 5. 恢复栈指针
|
||||
add_to_stack(store, 16)
|
||||
if status == 0:
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def get_pow_response(request, max_attempts: int = 3):
|
||||
"""获取 PoW 响应
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
max_attempts: 最大重试次数
|
||||
|
||||
Returns:
|
||||
Base64 编码的 PoW 响应,如果失败返回 None
|
||||
"""
|
||||
from .auth import get_auth_headers, choose_new_account
|
||||
from .deepseek import BASE_HEADERS, login_deepseek_via_account, DEEPSEEK_CREATE_POW_URL
|
||||
|
||||
pow_url = DEEPSEEK_CREATE_POW_URL
|
||||
|
||||
attempts = 0
|
||||
while attempts < max_attempts:
|
||||
headers = get_auth_headers(request)
|
||||
try:
|
||||
resp = requests.post(
|
||||
pow_url,
|
||||
headers=headers,
|
||||
json={"target_path": "/api/v0/chat/completion"},
|
||||
timeout=30,
|
||||
impersonate="safari15_3",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_pow_response] 请求异常: {e}")
|
||||
attempts += 1
|
||||
continue
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"[get_pow_response] JSON解析异常: {e}")
|
||||
data = {}
|
||||
if resp.status_code == 200 and data.get("code") == 0:
|
||||
challenge = data["data"]["biz_data"]["challenge"]
|
||||
difficulty = challenge.get("difficulty", 144000)
|
||||
expire_at = challenge.get("expire_at", 1680000000)
|
||||
try:
|
||||
answer = compute_pow_answer(
|
||||
challenge["algorithm"],
|
||||
challenge["challenge"],
|
||||
challenge["salt"],
|
||||
difficulty,
|
||||
expire_at,
|
||||
challenge["signature"],
|
||||
challenge["target_path"],
|
||||
WASM_PATH,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[get_pow_response] PoW 答案计算异常: {e}")
|
||||
answer = None
|
||||
if answer is None:
|
||||
logger.warning("[get_pow_response] PoW 答案计算失败,重试中...")
|
||||
resp.close()
|
||||
attempts += 1
|
||||
continue
|
||||
pow_dict = {
|
||||
"algorithm": challenge["algorithm"],
|
||||
"challenge": challenge["challenge"],
|
||||
"salt": challenge["salt"],
|
||||
"answer": answer,
|
||||
"signature": challenge["signature"],
|
||||
"target_path": challenge["target_path"],
|
||||
}
|
||||
pow_str = json.dumps(pow_dict, separators=(",", ":"), ensure_ascii=False)
|
||||
encoded = base64.b64encode(pow_str.encode("utf-8")).decode("utf-8").rstrip()
|
||||
resp.close()
|
||||
return encoded
|
||||
else:
|
||||
code = data.get("code")
|
||||
logger.warning(
|
||||
f"[get_pow_response] 获取 PoW 失败, code={code}, msg={data.get('msg')}"
|
||||
)
|
||||
resp.close()
|
||||
if request.state.use_config_token:
|
||||
current_id = get_account_identifier(request.state.account)
|
||||
if not hasattr(request.state, "tried_accounts"):
|
||||
request.state.tried_accounts = []
|
||||
if current_id not in request.state.tried_accounts:
|
||||
request.state.tried_accounts.append(current_id)
|
||||
new_account = choose_new_account(request.state.tried_accounts)
|
||||
if new_account is None:
|
||||
break
|
||||
try:
|
||||
login_deepseek_via_account(new_account)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[get_pow_response] 账号 {get_account_identifier(new_account)} 登录失败:{e}"
|
||||
)
|
||||
attempts += 1
|
||||
continue
|
||||
request.state.account = new_account
|
||||
request.state.deepseek_token = new_account.get("token")
|
||||
else:
|
||||
attempts += 1
|
||||
continue
|
||||
attempts += 1
|
||||
return None
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""会话管理模块 - 封装公共的会话创建和 PoW 获取逻辑"""
|
||||
from curl_cffi import requests as cffi_requests
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .config import logger
|
||||
from .utils import get_account_identifier
|
||||
from .models import get_model_config
|
||||
from .auth import (
|
||||
get_auth_headers,
|
||||
choose_new_account,
|
||||
release_account,
|
||||
refresh_account_token,
|
||||
)
|
||||
from .deepseek import (
|
||||
DEEPSEEK_CREATE_SESSION_URL,
|
||||
DEEPSEEK_CREATE_POW_URL,
|
||||
login_deepseek_via_account,
|
||||
call_completion_endpoint,
|
||||
)
|
||||
from .pow import get_pow_response
|
||||
|
||||
|
||||
def create_session(request: Request, max_attempts: int = 3) -> str | None:
|
||||
"""创建 DeepSeek 会话
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
max_attempts: 最大重试次数
|
||||
|
||||
Returns:
|
||||
会话 ID,如果失败返回 None
|
||||
"""
|
||||
attempts = 0
|
||||
token_refreshed = False # 标记是否已尝试刷新 token
|
||||
|
||||
while attempts < max_attempts:
|
||||
headers = get_auth_headers(request)
|
||||
try:
|
||||
resp = cffi_requests.post(
|
||||
DEEPSEEK_CREATE_SESSION_URL,
|
||||
headers=headers,
|
||||
json={"agent": "chat"},
|
||||
impersonate="safari15_3",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[create_session] 请求异常: {e}")
|
||||
attempts += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"[create_session] JSON解析异常: {e}")
|
||||
data = {}
|
||||
|
||||
if resp.status_code == 200 and data.get("code") == 0:
|
||||
session_id = data["data"]["biz_data"]["id"]
|
||||
resp.close()
|
||||
return session_id
|
||||
else:
|
||||
code = data.get("code")
|
||||
msg = data.get("msg", "")
|
||||
logger.warning(
|
||||
f"[create_session] 创建会话失败, code={code}, msg={msg}"
|
||||
)
|
||||
resp.close()
|
||||
|
||||
# 配置模式下尝试处理 token 问题
|
||||
if request.state.use_config_token:
|
||||
# token 无效(认证失败)时,先尝试刷新当前账号的 token
|
||||
if code in [40001, 40002, 40003] or "token" in msg.lower() or "unauthorized" in msg.lower():
|
||||
if not token_refreshed:
|
||||
logger.info("[create_session] 检测到 token 可能过期,尝试刷新")
|
||||
if refresh_account_token(request):
|
||||
token_refreshed = True
|
||||
continue # 使用新 token 重试
|
||||
else:
|
||||
logger.warning("[create_session] token 刷新失败,尝试切换账号")
|
||||
|
||||
# token 刷新失败或其他错误,尝试切换账号
|
||||
current_id = get_account_identifier(request.state.account)
|
||||
if not hasattr(request.state, "tried_accounts"):
|
||||
request.state.tried_accounts = []
|
||||
if current_id not in request.state.tried_accounts:
|
||||
request.state.tried_accounts.append(current_id)
|
||||
new_account = choose_new_account(request.state.tried_accounts)
|
||||
if new_account is None:
|
||||
break
|
||||
try:
|
||||
login_deepseek_via_account(new_account)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[create_session] 账号 {get_account_identifier(new_account)} 登录失败:{e}"
|
||||
)
|
||||
attempts += 1
|
||||
continue
|
||||
request.state.account = new_account
|
||||
request.state.deepseek_token = new_account.get("token")
|
||||
token_refreshed = False # 新账号重置刷新标记
|
||||
else:
|
||||
attempts += 1
|
||||
continue
|
||||
attempts += 1
|
||||
return None
|
||||
|
||||
|
||||
def get_pow(request: Request, max_attempts: int = 3) -> str | None:
|
||||
"""获取 PoW 响应的包装函数
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
max_attempts: 最大重试次数
|
||||
|
||||
Returns:
|
||||
Base64 编码的 PoW 响应,如果失败返回 None
|
||||
"""
|
||||
return get_pow_response(request, max_attempts)
|
||||
|
||||
|
||||
def prepare_completion_request(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
prompt: str,
|
||||
thinking_enabled: bool = False,
|
||||
search_enabled: bool = False,
|
||||
max_attempts: int = 3,
|
||||
):
|
||||
"""准备并执行对话补全请求
|
||||
|
||||
Args:
|
||||
request: FastAPI 请求对象
|
||||
session_id: 会话 ID
|
||||
prompt: 处理后的提示词
|
||||
thinking_enabled: 是否启用思考模式
|
||||
search_enabled: 是否启用搜索
|
||||
max_attempts: 最大重试次数
|
||||
|
||||
Returns:
|
||||
DeepSeek 响应对象,如果失败返回 None
|
||||
"""
|
||||
pow_resp = get_pow(request, max_attempts)
|
||||
if not pow_resp:
|
||||
return None
|
||||
|
||||
headers = {**get_auth_headers(request), "x-ds-pow-response": pow_resp}
|
||||
payload = {
|
||||
"chat_session_id": session_id,
|
||||
"parent_message_id": None,
|
||||
"prompt": prompt,
|
||||
"ref_file_ids": [],
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"search_enabled": search_enabled,
|
||||
}
|
||||
|
||||
return call_completion_endpoint(payload, headers, max_attempts)
|
||||
|
||||
|
||||
# get_model_config 已移至 core.models
|
||||
|
||||
|
||||
def cleanup_account(request: Request):
|
||||
"""清理账号资源(将账号放回队列)"""
|
||||
if getattr(request.state, "use_config_token", False) and hasattr(request.state, "account"):
|
||||
release_account(request.state.account)
|
||||
@@ -1,470 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""DeepSeek SSE 流解析模块
|
||||
|
||||
这个模块包含解析 DeepSeek SSE 响应的公共逻辑,供 openai.py、claude.py 和 accounts.py 共用。
|
||||
合并了原 sse_parser.py 和 stream_parser.py 的功能。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import List, Tuple, Optional, Dict, Any, Generator
|
||||
|
||||
from .config import logger
|
||||
from .constants import SKIP_PATTERNS
|
||||
|
||||
# 预编译正则表达式
|
||||
_TOOL_CALL_PATTERN = re.compile(r'\{\s*["\']tool_calls["\']\s*:\s*\[(.*?)\]\s*\}', re.DOTALL)
|
||||
_CITATION_PATTERN = re.compile(r"^\[citation:")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 基础解析函数
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def parse_deepseek_sse_line(raw_line: bytes) -> Optional[Dict[str, Any]]:
|
||||
"""解析 DeepSeek SSE 行
|
||||
|
||||
Args:
|
||||
raw_line: 原始字节行
|
||||
|
||||
Returns:
|
||||
解析后的 chunk 字典,如果解析失败或应跳过则返回 None
|
||||
"""
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"[parse_deepseek_sse_line] 解码失败: {e}")
|
||||
return None
|
||||
|
||||
if not line or not line.startswith("data:"):
|
||||
return None
|
||||
|
||||
data_str = line[5:].strip()
|
||||
|
||||
if data_str == "[DONE]":
|
||||
return {"type": "done"}
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
return chunk
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[parse_deepseek_sse_line] JSON解析失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def should_skip_chunk(chunk_path: str) -> bool:
|
||||
"""判断是否应该跳过这个 chunk(状态相关,不是内容)"""
|
||||
if chunk_path == "response/search_status":
|
||||
return True
|
||||
return any(kw in chunk_path for kw in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def is_response_finished(chunk_path: str, v_value: Any) -> bool:
|
||||
"""判断是否是响应结束信号"""
|
||||
return chunk_path == "response/status" and isinstance(v_value, str) and v_value == "FINISHED"
|
||||
|
||||
|
||||
def is_finished_signal(chunk_path: str, v_value: str) -> bool:
|
||||
"""判断字符串 v_value 是否是结束信号"""
|
||||
return v_value == "FINISHED" and (not chunk_path or chunk_path == "status")
|
||||
|
||||
|
||||
def is_search_result(item: dict) -> bool:
|
||||
"""判断是否是搜索结果项(url/title/snippet)"""
|
||||
return "url" in item and "title" in item
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 内容提取函数
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def extract_content_from_item(item: dict, default_type: str = "text") -> Optional[Tuple[str, str]]:
|
||||
"""从包含 content 和 type 的项中提取内容
|
||||
|
||||
返回 (content, content_type) 或 None
|
||||
"""
|
||||
if "content" in item and "type" in item:
|
||||
inner_type = item.get("type", "").upper()
|
||||
content = item.get("content", "")
|
||||
if content:
|
||||
if inner_type == "THINK" or inner_type == "THINKING":
|
||||
return (content, "thinking")
|
||||
elif inner_type == "RESPONSE":
|
||||
return (content, "text")
|
||||
else:
|
||||
return (content, default_type)
|
||||
return None
|
||||
|
||||
|
||||
def extract_content_recursive(items: List[Dict], default_type: str = "text") -> Optional[List[Tuple[str, str]]]:
|
||||
"""递归提取列表中的内容
|
||||
|
||||
返回 [(content, content_type), ...] 列表,
|
||||
如果遇到 FINISHED 信号返回 None
|
||||
"""
|
||||
extracted: List[Tuple[str, str]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
item_p = item.get("p", "")
|
||||
item_v = item.get("v")
|
||||
|
||||
# 跳过搜索结果项
|
||||
if is_search_result(item):
|
||||
continue
|
||||
|
||||
# 只有当 p="status" (精确匹配) 且 v="FINISHED" 才认为是真正结束
|
||||
if item_p == "status" and item_v == "FINISHED":
|
||||
return None # 信号结束
|
||||
|
||||
# 跳过状态相关
|
||||
if should_skip_chunk(item_p):
|
||||
continue
|
||||
|
||||
# 直接处理包含 content 和 type 的项
|
||||
result = extract_content_from_item(item, default_type)
|
||||
if result:
|
||||
extracted.append(result)
|
||||
continue
|
||||
|
||||
# 确定类型(基于 p 字段)
|
||||
if "thinking" in item_p:
|
||||
content_type = "thinking"
|
||||
elif "content" in item_p or item_p == "response" or item_p == "fragments":
|
||||
content_type = "text"
|
||||
else:
|
||||
content_type = default_type
|
||||
|
||||
# 处理不同的 v 类型
|
||||
if isinstance(item_v, str):
|
||||
if item_v and item_v != "FINISHED":
|
||||
extracted.append((item_v, content_type))
|
||||
elif isinstance(item_v, list):
|
||||
# 内层可能是 [{"content": "text", "type": "THINK/RESPONSE", ...}] 格式
|
||||
for inner in item_v:
|
||||
if isinstance(inner, dict):
|
||||
# 检查内层的 type 字段
|
||||
inner_type = inner.get("type", "").upper()
|
||||
# DeepSeek 使用 THINK 而不是 THINKING
|
||||
if inner_type == "THINK" or inner_type == "THINKING":
|
||||
final_type = "thinking"
|
||||
elif inner_type == "RESPONSE":
|
||||
final_type = "text"
|
||||
else:
|
||||
final_type = content_type # 继承外层类型
|
||||
|
||||
content = inner.get("content", "")
|
||||
if content:
|
||||
extracted.append((content, final_type))
|
||||
elif isinstance(inner, str) and inner:
|
||||
extracted.append((inner, content_type))
|
||||
return extracted
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 高级解析函数
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def parse_sse_chunk_for_content(
|
||||
chunk: Dict[str, Any],
|
||||
thinking_enabled: bool = False,
|
||||
current_fragment_type: str = "thinking"
|
||||
) -> Tuple[List[Tuple[str, str]], bool, str]:
|
||||
"""解析单个 SSE chunk 并提取内容
|
||||
|
||||
Args:
|
||||
chunk: 解析后的 JSON chunk
|
||||
thinking_enabled: 是否启用思考模式
|
||||
current_fragment_type: 当前活跃的 fragment 类型 ("thinking" 或 "text")
|
||||
用于处理没有明确路径的空 p 字段内容
|
||||
|
||||
Returns:
|
||||
(contents, is_finished, new_fragment_type)
|
||||
- contents: [(content, content_type), ...] 列表
|
||||
- is_finished: 是否是结束信号
|
||||
- new_fragment_type: 更新后的 fragment 类型,供下一个 chunk 使用
|
||||
"""
|
||||
if "v" not in chunk:
|
||||
return ([], False, current_fragment_type)
|
||||
|
||||
v_value = chunk["v"]
|
||||
chunk_path = chunk.get("p", "")
|
||||
contents: List[Tuple[str, str]] = []
|
||||
new_fragment_type = current_fragment_type
|
||||
|
||||
# 跳过状态相关 chunk
|
||||
if should_skip_chunk(chunk_path):
|
||||
return ([], False, current_fragment_type)
|
||||
|
||||
# 检查是否是真正的响应结束信号
|
||||
if is_response_finished(chunk_path, v_value):
|
||||
return ([], True, current_fragment_type)
|
||||
|
||||
# 检测 fragment 类型变化(来自 APPEND 操作)
|
||||
# 格式: {'p': 'response', 'o': 'BATCH', 'v': [{'p': 'fragments', 'o': 'APPEND', 'v': [{'type': 'THINK/RESPONSE', ...}]}]}
|
||||
if chunk_path == "response" and isinstance(v_value, list):
|
||||
for batch_item in v_value:
|
||||
if isinstance(batch_item, dict) and batch_item.get("p") == "fragments" and batch_item.get("o") == "APPEND":
|
||||
fragments = batch_item.get("v", [])
|
||||
for frag in fragments:
|
||||
if isinstance(frag, dict):
|
||||
frag_type = frag.get("type", "").upper()
|
||||
if frag_type == "THINK" or frag_type == "THINKING":
|
||||
new_fragment_type = "thinking"
|
||||
elif frag_type == "RESPONSE":
|
||||
new_fragment_type = "text"
|
||||
|
||||
# 也检测直接的 fragments 路径
|
||||
if "response/fragments" in chunk_path and isinstance(v_value, list):
|
||||
for frag in v_value:
|
||||
if isinstance(frag, dict):
|
||||
frag_type = frag.get("type", "").upper()
|
||||
if frag_type == "THINK" or frag_type == "THINKING":
|
||||
new_fragment_type = "thinking"
|
||||
elif frag_type == "RESPONSE":
|
||||
new_fragment_type = "text"
|
||||
|
||||
# 确定当前内容类型
|
||||
if chunk_path == "response/thinking_content":
|
||||
ptype = "thinking"
|
||||
elif chunk_path == "response/content":
|
||||
ptype = "text"
|
||||
elif "response/fragments" in chunk_path and "/content" in chunk_path:
|
||||
# 如 response/fragments/-1/content - 使用当前 fragment 类型
|
||||
ptype = new_fragment_type
|
||||
elif not chunk_path:
|
||||
# 空路径内容:使用当前活跃的 fragment 类型
|
||||
if thinking_enabled:
|
||||
ptype = new_fragment_type
|
||||
else:
|
||||
ptype = "text"
|
||||
else:
|
||||
ptype = "text"
|
||||
|
||||
# 处理字符串值
|
||||
if isinstance(v_value, str):
|
||||
if is_finished_signal(chunk_path, v_value):
|
||||
return ([], True, new_fragment_type)
|
||||
if v_value:
|
||||
contents.append((v_value, ptype))
|
||||
|
||||
# 处理列表值
|
||||
elif isinstance(v_value, list):
|
||||
result = extract_content_recursive(v_value, ptype)
|
||||
if result is None:
|
||||
return ([], True, new_fragment_type)
|
||||
contents.extend(result)
|
||||
|
||||
# 处理字典值(初始响应 chunk,包含 response.fragments)
|
||||
elif isinstance(v_value, dict):
|
||||
response_obj = v_value.get("response", v_value)
|
||||
fragments = response_obj.get("fragments", [])
|
||||
if isinstance(fragments, list):
|
||||
for frag in fragments:
|
||||
if isinstance(frag, dict):
|
||||
frag_type = frag.get("type", "").upper()
|
||||
frag_content = frag.get("content", "")
|
||||
if frag_type == "THINK" or frag_type == "THINKING":
|
||||
new_fragment_type = "thinking"
|
||||
if frag_content:
|
||||
contents.append((frag_content, "thinking"))
|
||||
elif frag_type == "RESPONSE":
|
||||
new_fragment_type = "text"
|
||||
if frag_content:
|
||||
contents.append((frag_content, "text"))
|
||||
elif frag_content:
|
||||
contents.append((frag_content, ptype))
|
||||
|
||||
return (contents, False, new_fragment_type)
|
||||
|
||||
|
||||
def extract_content_from_chunk(chunk: Dict[str, Any]) -> Tuple[str, str, bool]:
|
||||
"""从 DeepSeek chunk 中提取内容(简化版本,兼容旧接口)
|
||||
|
||||
Args:
|
||||
chunk: 解析后的 chunk 字典
|
||||
|
||||
Returns:
|
||||
(content, content_type, is_finished) 元组
|
||||
content_type 为 "thinking" 或 "text"
|
||||
is_finished 为 True 表示响应结束
|
||||
"""
|
||||
if chunk.get("type") == "done":
|
||||
return "", "text", True
|
||||
|
||||
# 检测内容审核/敏感词阻止
|
||||
if "error" in chunk or chunk.get("code") == "content_filter":
|
||||
logger.warning(f"[extract_content_from_chunk] 检测到内容过滤: {chunk}")
|
||||
return "", "text", True
|
||||
|
||||
if "v" not in chunk:
|
||||
return "", "text", False
|
||||
|
||||
v_value = chunk["v"]
|
||||
ptype = "text"
|
||||
|
||||
# 检查路径确定类型
|
||||
path = chunk.get("p", "")
|
||||
if path == "response/search_status":
|
||||
return "", "text", False # 跳过搜索状态
|
||||
elif path == "response/thinking_content":
|
||||
ptype = "thinking"
|
||||
elif path == "response/content":
|
||||
ptype = "text"
|
||||
|
||||
if isinstance(v_value, str):
|
||||
if v_value == "FINISHED":
|
||||
return "", ptype, True
|
||||
return v_value, ptype, False
|
||||
elif isinstance(v_value, list):
|
||||
for item in v_value:
|
||||
if isinstance(item, dict):
|
||||
if item.get("p") == "status" and item.get("v") == "FINISHED":
|
||||
return "", ptype, True
|
||||
return "", ptype, False
|
||||
|
||||
return "", ptype, False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 响应收集函数
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def collect_deepseek_response(response: Any) -> Tuple[str, str]:
|
||||
"""收集 DeepSeek 流响应的完整内容
|
||||
|
||||
Args:
|
||||
response: DeepSeek 流响应对象
|
||||
|
||||
Returns:
|
||||
(reasoning_content, text_content) 元组
|
||||
"""
|
||||
thinking_parts: List[str] = []
|
||||
text_parts: List[str] = []
|
||||
|
||||
try:
|
||||
for raw_line in response.iter_lines():
|
||||
chunk = parse_deepseek_sse_line(raw_line)
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
content, content_type, is_finished = extract_content_from_chunk(chunk)
|
||||
|
||||
if is_finished:
|
||||
break
|
||||
|
||||
if content:
|
||||
if content_type == "thinking":
|
||||
thinking_parts.append(content)
|
||||
else:
|
||||
text_parts.append(content)
|
||||
except Exception as e:
|
||||
logger.error(f"[collect_deepseek_response] 收集响应失败: {e}")
|
||||
finally:
|
||||
try:
|
||||
response.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "".join(thinking_parts), "".join(text_parts)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 工具调用解析
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def parse_tool_calls(text: str, tools_requested: List[Dict]) -> List[Dict[str, Any]]:
|
||||
"""从响应文本中解析工具调用
|
||||
|
||||
Args:
|
||||
text: 响应文本
|
||||
tools_requested: 请求中定义的工具列表
|
||||
|
||||
Returns:
|
||||
检测到的工具调用列表,每项包含 name 和 input
|
||||
"""
|
||||
detected_tools: List[Dict[str, Any]] = []
|
||||
cleaned_text = text.strip()
|
||||
|
||||
# 尝试直接解析完整 JSON
|
||||
if cleaned_text.startswith('{"tool_calls":') and cleaned_text.endswith("]}"):
|
||||
try:
|
||||
tool_data = json.loads(cleaned_text)
|
||||
for tool_call in tool_data.get("tool_calls", []):
|
||||
tool_name = tool_call.get("name")
|
||||
tool_input = tool_call.get("input", {})
|
||||
if any(tool.get("name") == tool_name for tool in tools_requested):
|
||||
detected_tools.append({"name": tool_name, "input": tool_input})
|
||||
if detected_tools:
|
||||
return detected_tools
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 使用正则匹配
|
||||
matches = _TOOL_CALL_PATTERN.findall(cleaned_text)
|
||||
for match in matches:
|
||||
try:
|
||||
tool_calls_json = f'{{"tool_calls": [{match}]}}'
|
||||
tool_data = json.loads(tool_calls_json)
|
||||
for tool_call in tool_data.get("tool_calls", []):
|
||||
tool_name = tool_call.get("name")
|
||||
tool_input = tool_call.get("input", {})
|
||||
if any(tool.get("name") == tool_name for tool in tools_requested):
|
||||
detected_tools.append({"name": tool_name, "input": tool_input})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return detected_tools
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 引用过滤
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def should_filter_citation(text: str, search_enabled: bool) -> bool:
|
||||
"""检查是否应该过滤引用内容
|
||||
|
||||
Args:
|
||||
text: 内容文本
|
||||
search_enabled: 是否启用搜索
|
||||
|
||||
Returns:
|
||||
是否应该过滤
|
||||
"""
|
||||
if not search_enabled:
|
||||
return False
|
||||
return _CITATION_PATTERN.match(text) is not None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 工具调用格式化
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def format_openai_tool_calls(
|
||||
detected_tools: List[Dict[str, Any]],
|
||||
base_id: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将检测到的工具调用格式化为 OpenAI API 格式
|
||||
|
||||
Args:
|
||||
detected_tools: parse_tool_calls 返回的工具调用列表
|
||||
base_id: 用于生成唯一 ID 的基础字符串(可选)
|
||||
|
||||
Returns:
|
||||
OpenAI 格式的 tool_calls 数组,例如:
|
||||
[{"id": "call_xxx", "type": "function", "function": {"name": "...", "arguments": "..."}}]
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
|
||||
tool_calls_data = []
|
||||
for idx, tool_info in enumerate(detected_tools):
|
||||
tool_calls_data.append({
|
||||
"id": f"call_{base_id or int(time.time())}_{random.randint(1000,9999)}_{idx}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_info["name"],
|
||||
"arguments": json.dumps(tool_info.get("input", {}), ensure_ascii=False)
|
||||
}
|
||||
})
|
||||
return tool_calls_data
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""公共工具函数模块"""
|
||||
|
||||
|
||||
def get_account_identifier(account: dict) -> str:
|
||||
"""返回账号的唯一标识,优先使用 email,否则使用 mobile"""
|
||||
return account.get("email", "").strip() or account.get("mobile", "").strip()
|
||||
|
||||
|
||||
def estimate_tokens(text) -> int:
|
||||
"""估算文本的 token 数量(简单估算:字符数/4)
|
||||
|
||||
Args:
|
||||
text: 字符串或其他类型
|
||||
|
||||
Returns:
|
||||
估算的 token 数量,最小为 1
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
return max(1, len(text) // 4)
|
||||
elif isinstance(text, list):
|
||||
return sum(
|
||||
estimate_tokens(item.get("text", ""))
|
||||
if isinstance(item, dict)
|
||||
else estimate_tokens(str(item))
|
||||
for item in text
|
||||
)
|
||||
else:
|
||||
return max(1, len(str(text)) // 4)
|
||||
151
dev.py
151
dev.py
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API 开发服务器 - 统一启动后端和前端
|
||||
|
||||
使用方法:
|
||||
python dev.py # 同时启动后端和前端
|
||||
python dev.py --backend # 仅启动后端
|
||||
python dev.py --frontend # 仅启动前端
|
||||
python dev.py --install # 安装所有依赖
|
||||
|
||||
环境变量:
|
||||
PORT - 后端服务端口,默认 5001
|
||||
LOG_LEVEL - 日志级别,默认 INFO
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 配置
|
||||
BACKEND_PORT = int(os.getenv("PORT", "5001"))
|
||||
FRONTEND_PORT = 5173
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "info").lower()
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
WEBUI_DIR = PROJECT_DIR / "webui"
|
||||
REQUIREMENTS_FILE = PROJECT_DIR / "requirements.txt"
|
||||
|
||||
processes = []
|
||||
|
||||
|
||||
def install_dependencies():
|
||||
"""安装所有 Python 和 Node.js 依赖"""
|
||||
print("\n📦 安装 Python 依赖...")
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS_FILE), "-q"
|
||||
], check=True)
|
||||
print("✅ Python 依赖安装完成")
|
||||
|
||||
if WEBUI_DIR.exists():
|
||||
print("\n📦 安装前端依赖...")
|
||||
subprocess.run(["npm", "install"], cwd=WEBUI_DIR, check=True)
|
||||
print("✅ 前端依赖安装完成")
|
||||
|
||||
print("\n🎉 所有依赖安装完成!运行 `python dev.py` 启动服务\n")
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""处理退出信号,终止所有子进程"""
|
||||
print("\n\n🛑 正在关闭所有服务...")
|
||||
for proc in processes:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
print("👋 已退出\n")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def start_backend():
|
||||
"""启动后端服务"""
|
||||
print(f"🚀 启动后端服务... http://localhost:{BACKEND_PORT}")
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn",
|
||||
"app:app",
|
||||
"--host", HOST,
|
||||
"--port", str(BACKEND_PORT),
|
||||
"--reload",
|
||||
"--reload-dir", str(PROJECT_DIR),
|
||||
"--log-level", LOG_LEVEL,
|
||||
],
|
||||
cwd=PROJECT_DIR,
|
||||
)
|
||||
processes.append(proc)
|
||||
return proc
|
||||
|
||||
|
||||
def start_frontend():
|
||||
"""启动前端开发服务器"""
|
||||
if not WEBUI_DIR.exists():
|
||||
print("⚠️ webui 目录不存在,跳过前端启动")
|
||||
return None
|
||||
|
||||
node_modules = WEBUI_DIR / "node_modules"
|
||||
if not node_modules.exists():
|
||||
print("📦 安装前端依赖...")
|
||||
subprocess.run(["npm", "install"], cwd=WEBUI_DIR, check=True)
|
||||
|
||||
print(f"🎨 启动前端服务... http://localhost:{FRONTEND_PORT}")
|
||||
proc = subprocess.Popen(
|
||||
["npm", "run", "dev"],
|
||||
cwd=WEBUI_DIR,
|
||||
)
|
||||
processes.append(proc)
|
||||
return proc
|
||||
|
||||
|
||||
def main():
|
||||
# 解析参数
|
||||
if "--install" in sys.argv or "-i" in sys.argv:
|
||||
install_dependencies()
|
||||
return
|
||||
|
||||
backend_only = "--backend" in sys.argv or "-b" in sys.argv
|
||||
frontend_only = "--frontend" in sys.argv or "-f" in sys.argv
|
||||
|
||||
# 注册信号处理
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" DS2API 开发服务器")
|
||||
print("=" * 50)
|
||||
|
||||
if frontend_only:
|
||||
start_frontend()
|
||||
elif backend_only:
|
||||
start_backend()
|
||||
else:
|
||||
# 同时启动
|
||||
start_backend()
|
||||
time.sleep(1) # 等待后端启动
|
||||
start_frontend()
|
||||
|
||||
print("\n" + "-" * 50)
|
||||
if not frontend_only:
|
||||
print(f"📡 后端 API: http://localhost:{BACKEND_PORT}")
|
||||
if not backend_only:
|
||||
print(f"🎨 管理界面: http://localhost:{FRONTEND_PORT}")
|
||||
print("-" * 50)
|
||||
print("按 Ctrl+C 停止所有服务\n")
|
||||
|
||||
# 等待进程结束
|
||||
try:
|
||||
while processes:
|
||||
for proc in processes[:]:
|
||||
if proc.poll() is not None:
|
||||
processes.remove(proc)
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
signal_handler(None, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,22 +9,12 @@
|
||||
|
||||
services:
|
||||
ds2api:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
target: go-builder
|
||||
image: ds2api:dev
|
||||
container_name: ds2api-dev
|
||||
command: [
|
||||
"uvicorn",
|
||||
"app:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"5001",
|
||||
"--reload",
|
||||
"--reload-dir",
|
||||
"/app",
|
||||
"--log-level",
|
||||
"debug"
|
||||
]
|
||||
command: ["go", "run", "./cmd/ds2api"]
|
||||
ports:
|
||||
- "${PORT:-5001}:5001"
|
||||
env_file:
|
||||
@@ -34,10 +24,7 @@ services:
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
# 源代码挂载(开发时实时生效)
|
||||
- ./app.py:/app/app.py:ro
|
||||
- ./core:/app/core:ro
|
||||
- ./routes:/app/routes:ro
|
||||
- ./static:/app/static:ro
|
||||
- ./:/app
|
||||
# 配置文件挂载(便于本地修改)
|
||||
- ./config.json:/app/config.json
|
||||
restart: "no"
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
# DS2API 生产环境配置
|
||||
# 使用说明:
|
||||
# 1. 复制 .env.example 为 .env 并填写配置
|
||||
# 2. docker-compose up -d
|
||||
# 3. 主代码更新后:docker-compose up -d --build
|
||||
#
|
||||
# 设计原则:
|
||||
# - 零侵入:所有项目配置通过 .env 文件传递
|
||||
# - 易维护:主代码更新只需重新构建镜像
|
||||
|
||||
services:
|
||||
ds2api:
|
||||
build: .
|
||||
@@ -18,11 +8,10 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 确保容器内使用正确的主机绑定
|
||||
- HOST=0.0.0.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/v1/models')"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5001/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module ds2api
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.0.6
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/refraction-networking/utls v1.8.1
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
)
|
||||
16
go.sum
Normal file
16
go.sum
Normal file
@@ -0,0 +1,16 @@
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
190
internal/account/pool.go
Normal file
190
internal/account/pool.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
type Pool struct {
|
||||
store *config.Store
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
inUse map[string]int
|
||||
maxInflightPerAccount int
|
||||
recommendedConcurrency int
|
||||
}
|
||||
|
||||
func NewPool(store *config.Store) *Pool {
|
||||
p := &Pool{
|
||||
store: store,
|
||||
inUse: map[string]int{},
|
||||
maxInflightPerAccount: maxInflightFromEnv(),
|
||||
}
|
||||
p.Reset()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Pool) Reset() {
|
||||
accounts := p.store.Accounts()
|
||||
sort.SliceStable(accounts, func(i, j int) bool {
|
||||
iHas := accounts[i].Token != ""
|
||||
jHas := accounts[j].Token != ""
|
||||
if iHas == jHas {
|
||||
return i < j
|
||||
}
|
||||
return iHas
|
||||
})
|
||||
ids := make([]string, 0, len(accounts))
|
||||
for _, a := range accounts {
|
||||
id := a.Identifier()
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
recommended := defaultRecommendedConcurrency(len(ids), p.maxInflightPerAccount)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.queue = ids
|
||||
p.inUse = map[string]int{}
|
||||
p.recommendedConcurrency = recommended
|
||||
config.Logger.Info(
|
||||
"[init_account_queue] initialized",
|
||||
"total", len(ids),
|
||||
"max_inflight_per_account", p.maxInflightPerAccount,
|
||||
"recommended_concurrency", p.recommendedConcurrency,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *Pool) Acquire(target string, exclude map[string]bool) (config.Account, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if exclude == nil {
|
||||
exclude = map[string]bool{}
|
||||
}
|
||||
if target != "" {
|
||||
if exclude[target] || p.inUse[target] >= p.maxInflightPerAccount {
|
||||
return config.Account{}, false
|
||||
}
|
||||
acc, ok := p.store.FindAccount(target)
|
||||
if !ok {
|
||||
return config.Account{}, false
|
||||
}
|
||||
p.inUse[target]++
|
||||
p.bumpQueue(target)
|
||||
return acc, true
|
||||
}
|
||||
|
||||
if acc, ok := p.tryAcquire(exclude, true); ok {
|
||||
return acc, true
|
||||
}
|
||||
if acc, ok := p.tryAcquire(exclude, false); ok {
|
||||
return acc, true
|
||||
}
|
||||
return config.Account{}, false
|
||||
}
|
||||
|
||||
func (p *Pool) tryAcquire(exclude map[string]bool, requireToken bool) (config.Account, bool) {
|
||||
for i := 0; i < len(p.queue); i++ {
|
||||
id := p.queue[i]
|
||||
if exclude[id] || p.inUse[id] >= p.maxInflightPerAccount {
|
||||
continue
|
||||
}
|
||||
acc, ok := p.store.FindAccount(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if requireToken && acc.Token == "" {
|
||||
continue
|
||||
}
|
||||
p.inUse[id]++
|
||||
p.bumpQueue(id)
|
||||
return acc, true
|
||||
}
|
||||
return config.Account{}, false
|
||||
}
|
||||
|
||||
func (p *Pool) bumpQueue(accountID string) {
|
||||
for i, id := range p.queue {
|
||||
if id != accountID {
|
||||
continue
|
||||
}
|
||||
p.queue = append(p.queue[:i], p.queue[i+1:]...)
|
||||
p.queue = append(p.queue, accountID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Release(accountID string) {
|
||||
if accountID == "" {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
count := p.inUse[accountID]
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
delete(p.inUse, accountID)
|
||||
return
|
||||
}
|
||||
p.inUse[accountID] = count - 1
|
||||
}
|
||||
|
||||
func (p *Pool) Status() map[string]any {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
available := make([]string, 0, len(p.queue))
|
||||
inUseAccounts := make([]string, 0, len(p.inUse))
|
||||
inUseSlots := 0
|
||||
for _, id := range p.queue {
|
||||
if p.inUse[id] < p.maxInflightPerAccount {
|
||||
available = append(available, id)
|
||||
}
|
||||
}
|
||||
for id, count := range p.inUse {
|
||||
if count > 0 {
|
||||
inUseAccounts = append(inUseAccounts, id)
|
||||
inUseSlots += count
|
||||
}
|
||||
}
|
||||
sort.Strings(inUseAccounts)
|
||||
return map[string]any{
|
||||
"available": len(available),
|
||||
"in_use": inUseSlots,
|
||||
"total": len(p.store.Accounts()),
|
||||
"available_accounts": available,
|
||||
"in_use_accounts": inUseAccounts,
|
||||
"max_inflight_per_account": p.maxInflightPerAccount,
|
||||
"recommended_concurrency": p.recommendedConcurrency,
|
||||
}
|
||||
}
|
||||
|
||||
func maxInflightFromEnv() int {
|
||||
for _, key := range []string{"DS2API_ACCOUNT_MAX_INFLIGHT", "DS2API_ACCOUNT_CONCURRENCY"} {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func defaultRecommendedConcurrency(accountCount, maxInflightPerAccount int) int {
|
||||
if accountCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
if maxInflightPerAccount <= 0 {
|
||||
maxInflightPerAccount = 2
|
||||
}
|
||||
return accountCount * maxInflightPerAccount
|
||||
}
|
||||
154
internal/account/pool_test.go
Normal file
154
internal/account/pool_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func newPoolForTest(t *testing.T, maxInflight string) *Pool {
|
||||
t.Helper()
|
||||
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", maxInflight)
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"accounts":[
|
||||
{"email":"acc1@example.com","token":"token1"},
|
||||
{"email":"acc2@example.com","token":"token2"}
|
||||
]
|
||||
}`)
|
||||
store := config.LoadStore()
|
||||
return NewPool(store)
|
||||
}
|
||||
|
||||
func TestPoolRoundRobinWithConcurrentSlots(t *testing.T) {
|
||||
pool := newPoolForTest(t, "2")
|
||||
|
||||
order := make([]string, 0, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
acc, ok := pool.Acquire("", nil)
|
||||
if !ok {
|
||||
t.Fatalf("expected acquire success at step %d", i+1)
|
||||
}
|
||||
order = append(order, acc.Identifier())
|
||||
}
|
||||
want := []string{"acc1@example.com", "acc2@example.com", "acc1@example.com", "acc2@example.com"}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("unexpected order at %d: got %q want %q (full=%v)", i, order[i], want[i], order)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := pool.Acquire("", nil); ok {
|
||||
t.Fatalf("expected acquire to fail when all inflight slots are occupied")
|
||||
}
|
||||
|
||||
pool.Release("acc1@example.com")
|
||||
acc, ok := pool.Acquire("", nil)
|
||||
if !ok || acc.Identifier() != "acc1@example.com" {
|
||||
t.Fatalf("expected reacquire acc1 after releasing one slot, got ok=%v id=%q", ok, acc.Identifier())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolTargetAccountInflightLimit(t *testing.T) {
|
||||
pool := newPoolForTest(t, "2")
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, ok := pool.Acquire("acc1@example.com", nil); !ok {
|
||||
t.Fatalf("expected target acquire success at step %d", i+1)
|
||||
}
|
||||
}
|
||||
if _, ok := pool.Acquire("acc1@example.com", nil); ok {
|
||||
t.Fatalf("expected third acquire on same target to fail due to inflight limit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolConcurrentAcquireDistribution(t *testing.T) {
|
||||
pool := newPoolForTest(t, "2")
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan string, 6)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
acc, ok := pool.Acquire("", nil)
|
||||
if !ok {
|
||||
results <- "FAIL"
|
||||
return
|
||||
}
|
||||
results <- acc.Identifier()
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
success := 0
|
||||
fail := 0
|
||||
perAccount := map[string]int{}
|
||||
for id := range results {
|
||||
if id == "FAIL" {
|
||||
fail++
|
||||
continue
|
||||
}
|
||||
success++
|
||||
perAccount[id]++
|
||||
}
|
||||
if success != 4 || fail != 2 {
|
||||
t.Fatalf("unexpected concurrent acquire result: success=%d fail=%d perAccount=%v", success, fail, perAccount)
|
||||
}
|
||||
for id, n := range perAccount {
|
||||
if n > 2 {
|
||||
t.Fatalf("account %s exceeded inflight limit: %d", id, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolStatusRecommendedConcurrencyDefault(t *testing.T) {
|
||||
pool := newPoolForTest(t, "")
|
||||
status := pool.Status()
|
||||
|
||||
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 2 {
|
||||
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
||||
}
|
||||
if got, ok := status["recommended_concurrency"].(int); !ok || got != 4 {
|
||||
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolStatusRecommendedConcurrencyRespectsOverride(t *testing.T) {
|
||||
pool := newPoolForTest(t, "3")
|
||||
status := pool.Status()
|
||||
|
||||
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 3 {
|
||||
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
||||
}
|
||||
if got, ok := status["recommended_concurrency"].(int); !ok || got != 6 {
|
||||
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolAccountConcurrencyAliasEnv(t *testing.T) {
|
||||
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "")
|
||||
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "4")
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
"accounts":[
|
||||
{"email":"acc1@example.com","token":"token1"},
|
||||
{"email":"acc2@example.com","token":"token2"}
|
||||
]
|
||||
}`)
|
||||
|
||||
pool := NewPool(config.LoadStore())
|
||||
status := pool.Status()
|
||||
if got, ok := status["max_inflight_per_account"].(int); !ok || got != 4 {
|
||||
t.Fatalf("unexpected max_inflight_per_account: %#v", status["max_inflight_per_account"])
|
||||
}
|
||||
if got, ok := status["recommended_concurrency"].(int); !ok || got != 8 {
|
||||
t.Fatalf("unexpected recommended_concurrency: %#v", status["recommended_concurrency"])
|
||||
}
|
||||
}
|
||||
403
internal/adapter/claude/handler.go
Normal file
403
internal/adapter/claude/handler.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
"ds2api/internal/sse"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *config.Store
|
||||
Auth *auth.Resolver
|
||||
DS *deepseek.Client
|
||||
}
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/anthropic/v1/models", h.ListModels)
|
||||
r.Post("/anthropic/v1/messages", h.Messages)
|
||||
r.Post("/anthropic/v1/messages/count_tokens", h.CountTokens)
|
||||
}
|
||||
|
||||
func (h *Handler) ListModels(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, config.ClaudeModelsResponse())
|
||||
}
|
||||
|
||||
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := h.Auth.Determine(r)
|
||||
if err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
detail := err.Error()
|
||||
if err == auth.ErrNoAccount {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": detail}})
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "invalid json"}})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
messagesRaw, _ := req["messages"].([]any)
|
||||
if model == "" || len(messagesRaw) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "Request must include 'model' and 'messages'."}})
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeClaudeMessages(messagesRaw)
|
||||
payload := cloneMap(req)
|
||||
payload["messages"] = normalized
|
||||
toolsRequested, _ := req["tools"].([]any)
|
||||
if len(toolsRequested) > 0 && !hasSystemMessage(normalized) {
|
||||
payload["messages"] = append([]any{map[string]any{"role": "system", "content": buildClaudeToolPrompt(toolsRequested)}}, normalized...)
|
||||
}
|
||||
|
||||
dsPayload := util.ConvertClaudeToDeepSeek(payload, h.Store)
|
||||
dsModel, _ := dsPayload["model"].(string)
|
||||
thinkingEnabled, searchEnabled, ok := config.GetModelConfig(dsModel)
|
||||
if !ok {
|
||||
thinkingEnabled = false
|
||||
searchEnabled = false
|
||||
}
|
||||
_ = searchEnabled
|
||||
finalPrompt := util.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
|
||||
|
||||
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"type": "api_error", "message": "invalid token."}})
|
||||
return
|
||||
}
|
||||
pow, err := h.DS.GetPow(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"type": "api_error", "message": "Failed to get PoW"}})
|
||||
return
|
||||
}
|
||||
requestPayload := map[string]any{
|
||||
"chat_session_id": sessionID,
|
||||
"parent_message_id": nil,
|
||||
"prompt": finalPrompt,
|
||||
"ref_file_ids": []any{},
|
||||
"thinking_enabled": thinkingEnabled,
|
||||
"search_enabled": searchEnabled,
|
||||
}
|
||||
resp, err := h.DS.CallCompletion(r.Context(), a, requestPayload, pow, 3)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"type": "api_error", "message": "Failed to get Claude response."}})
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"type": "api_error", "message": string(body)}})
|
||||
return
|
||||
}
|
||||
|
||||
fullText, fullThinking := collectDeepSeek(resp, thinkingEnabled)
|
||||
toolNames := extractClaudeToolNames(toolsRequested)
|
||||
detected := util.ParseToolCalls(fullText, toolNames)
|
||||
if toBool(req["stream"]) {
|
||||
h.writeClaudeStream(w, r, model, normalized, fullText, detected)
|
||||
return
|
||||
}
|
||||
content := make([]map[string]any, 0, 4)
|
||||
if fullThinking != "" {
|
||||
content = append(content, map[string]any{"type": "thinking", "thinking": fullThinking})
|
||||
}
|
||||
stopReason := "end_turn"
|
||||
if len(detected) > 0 {
|
||||
stopReason = "tool_use"
|
||||
for i, tc := range detected {
|
||||
content = append(content, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), i),
|
||||
"name": tc.Name,
|
||||
"input": tc.Input,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if fullText == "" {
|
||||
fullText = "抱歉,没有生成有效的响应内容。"
|
||||
}
|
||||
content = append(content, map[string]any{"type": "text", "text": fullText})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": content,
|
||||
"stop_reason": stopReason,
|
||||
"stop_sequence": nil,
|
||||
"usage": map[string]any{
|
||||
"input_tokens": util.EstimateTokens(fmt.Sprintf("%v", normalized)),
|
||||
"output_tokens": util.EstimateTokens(fullThinking) + util.EstimateTokens(fullText),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := h.Auth.Determine(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
messages, _ := req["messages"].([]any)
|
||||
if model == "" || len(messages) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Request must include 'model' and 'messages'."})
|
||||
return
|
||||
}
|
||||
inputTokens := 0
|
||||
if sys, ok := req["system"].(string); ok {
|
||||
inputTokens += util.EstimateTokens(sys)
|
||||
}
|
||||
for _, item := range messages {
|
||||
msg, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inputTokens += 2
|
||||
inputTokens += util.EstimateTokens(extractMessageContent(msg["content"]))
|
||||
}
|
||||
if tools, ok := req["tools"].([]any); ok {
|
||||
for _, t := range tools {
|
||||
b, _ := json.Marshal(t)
|
||||
inputTokens += util.EstimateTokens(string(b))
|
||||
}
|
||||
}
|
||||
if inputTokens < 1 {
|
||||
inputTokens = 1
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"input_tokens": inputTokens})
|
||||
}
|
||||
|
||||
func collectDeepSeek(resp *http.Response, thinkingEnabled bool) (string, string) {
|
||||
defer resp.Body.Close()
|
||||
text := strings.Builder{}
|
||||
thinking := strings.Builder{}
|
||||
currentType := "text"
|
||||
if thinkingEnabled {
|
||||
currentType = "thinking"
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
chunk, done, ok := sse.ParseDeepSeekSSELine(scanner.Bytes())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
parts, finished, newType := sse.ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||
currentType = newType
|
||||
if finished {
|
||||
break
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p.Type == "thinking" {
|
||||
thinking.WriteString(p.Text)
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.String(), thinking.String()
|
||||
}
|
||||
|
||||
func (h *Handler) writeClaudeStream(w http.ResponseWriter, r *http.Request, model string, messages []any, fullText string, detected []util.ParsedToolCall) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"type": "api_error", "message": "streaming unsupported"}})
|
||||
return
|
||||
}
|
||||
send := func(v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
_, _ = w.Write([]byte("data: "))
|
||||
_, _ = w.Write(b)
|
||||
_, _ = w.Write([]byte("\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
messageID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
||||
inputTokens := util.EstimateTokens(fmt.Sprintf("%v", messages))
|
||||
send(map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": messageID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": []any{},
|
||||
"stop_reason": nil,
|
||||
"stop_sequence": nil,
|
||||
"usage": map[string]any{"input_tokens": inputTokens, "output_tokens": 0},
|
||||
},
|
||||
})
|
||||
outputTokens := 0
|
||||
stopReason := "end_turn"
|
||||
if len(detected) > 0 {
|
||||
stopReason = "tool_use"
|
||||
for i, tc := range detected {
|
||||
send(map[string]any{"type": "content_block_start", "index": i, "content_block": map[string]any{"type": "tool_use", "id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), i), "name": tc.Name, "input": tc.Input}})
|
||||
send(map[string]any{"type": "content_block_stop", "index": i})
|
||||
outputTokens += util.EstimateTokens(fmt.Sprintf("%v", tc.Input))
|
||||
}
|
||||
} else {
|
||||
if fullText != "" {
|
||||
send(map[string]any{"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}})
|
||||
send(map[string]any{"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": fullText}})
|
||||
send(map[string]any{"type": "content_block_stop", "index": 0})
|
||||
outputTokens = util.EstimateTokens(fullText)
|
||||
}
|
||||
}
|
||||
send(map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil}, "usage": map[string]any{"output_tokens": outputTokens}})
|
||||
send(map[string]any{"type": "message_stop"})
|
||||
_ = r
|
||||
}
|
||||
|
||||
func normalizeClaudeMessages(messages []any) []any {
|
||||
out := make([]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
msg, ok := m.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
copied := cloneMap(msg)
|
||||
switch content := msg["content"].(type) {
|
||||
case []any:
|
||||
parts := make([]string, 0, len(content))
|
||||
for _, block := range content {
|
||||
b, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeStr, _ := b["type"].(string)
|
||||
if typeStr == "text" {
|
||||
if t, ok := b["text"].(string); ok {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
if typeStr == "tool_result" {
|
||||
parts = append(parts, fmt.Sprintf("%v", b["content"]))
|
||||
}
|
||||
}
|
||||
copied["content"] = strings.Join(parts, "\n")
|
||||
}
|
||||
out = append(out, copied)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildClaudeToolPrompt(tools []any) string {
|
||||
parts := []string{"You are Claude, a helpful AI assistant. You have access to these tools:"}
|
||||
for _, t := range tools {
|
||||
m, ok := t.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := m["name"].(string)
|
||||
desc, _ := m["description"].(string)
|
||||
schema, _ := json.Marshal(m["input_schema"])
|
||||
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
|
||||
}
|
||||
parts = append(parts, "When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}")
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func hasSystemMessage(messages []any) bool {
|
||||
for _, m := range messages {
|
||||
msg, ok := m.(map[string]any)
|
||||
if ok && msg["role"] == "system" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractClaudeToolNames(tools []any) []string {
|
||||
out := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
m, ok := t.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name, ok := m["name"].(string); ok && name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toMessageMaps(v any) []map[string]any {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractMessageContent(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []any:
|
||||
parts := make([]string, 0, len(x))
|
||||
for _, it := range x {
|
||||
parts = append(parts, fmt.Sprintf("%v", it))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toBool(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
471
internal/adapter/openai/handler.go
Normal file
471
internal/adapter/openai/handler.go
Normal file
@@ -0,0 +1,471 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
"ds2api/internal/sse"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *config.Store
|
||||
Auth *auth.Resolver
|
||||
DS *deepseek.Client
|
||||
}
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/v1/models", h.ListModels)
|
||||
r.Post("/v1/chat/completions", h.ChatCompletions)
|
||||
}
|
||||
|
||||
func (h *Handler) ListModels(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, config.OpenAIModelsResponse())
|
||||
}
|
||||
|
||||
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := h.Auth.Determine(r)
|
||||
if err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
detail := err.Error()
|
||||
if err == auth.ErrNoAccount {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
writeOpenAIError(w, status, detail)
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
r = r.WithContext(auth.WithAuth(r.Context(), a))
|
||||
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
messagesRaw, _ := req["messages"].([]any)
|
||||
if model == "" || len(messagesRaw) == 0 {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "Request must include 'model' and 'messages'.")
|
||||
return
|
||||
}
|
||||
thinkingEnabled, searchEnabled, ok := config.GetModelConfig(model)
|
||||
if !ok {
|
||||
writeOpenAIError(w, http.StatusServiceUnavailable, fmt.Sprintf("Model '%s' is not available.", model))
|
||||
return
|
||||
}
|
||||
|
||||
messages := normalizeMessages(messagesRaw)
|
||||
toolNames := []string{}
|
||||
if tools, ok := req["tools"].([]any); ok && len(tools) > 0 {
|
||||
messages, toolNames = injectToolPrompt(messages, tools)
|
||||
}
|
||||
finalPrompt := util.MessagesPrepare(messages)
|
||||
|
||||
sessionID, err := h.DS.CreateSession(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
if a.UseConfigToken {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Account token is invalid. Please re-login the account in admin.")
|
||||
} else {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Invalid token. If this should be a DS2API key, add it to config.keys first.")
|
||||
}
|
||||
return
|
||||
}
|
||||
pow, err := h.DS.GetPow(r.Context(), a, 3)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "Failed to get PoW (invalid token or unknown error).")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"chat_session_id": sessionID,
|
||||
"parent_message_id": nil,
|
||||
"prompt": finalPrompt,
|
||||
"ref_file_ids": []any{},
|
||||
"thinking_enabled": thinkingEnabled,
|
||||
"search_enabled": searchEnabled,
|
||||
}
|
||||
resp, err := h.DS.CallCompletion(r.Context(), a, payload, pow, 3)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
|
||||
return
|
||||
}
|
||||
if toBool(req["stream"]) {
|
||||
h.handleStream(w, r, resp, sessionID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames)
|
||||
return
|
||||
}
|
||||
h.handleNonStream(w, r.Context(), resp, sessionID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames)
|
||||
}
|
||||
|
||||
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
thinking := strings.Builder{}
|
||||
text := strings.Builder{}
|
||||
currentType := "text"
|
||||
if thinkingEnabled {
|
||||
currentType = "thinking"
|
||||
}
|
||||
_ = ctx
|
||||
_ = deepseek.ScanSSELines(resp, func(line []byte) bool {
|
||||
chunk, done, ok := sse.ParseDeepSeekSSELine(line)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if done {
|
||||
return false
|
||||
}
|
||||
if _, hasErr := chunk["error"]; hasErr {
|
||||
return false
|
||||
}
|
||||
parts, finished, newType := sse.ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||
currentType = newType
|
||||
if finished {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if searchEnabled && sse.IsCitation(p.Text) {
|
||||
continue
|
||||
}
|
||||
if p.Type == "thinking" {
|
||||
thinking.WriteString(p.Text)
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
finalThinking := thinking.String()
|
||||
finalText := text.String()
|
||||
detected := util.ParseToolCalls(finalText, toolNames)
|
||||
finishReason := "stop"
|
||||
messageObj := map[string]any{"role": "assistant", "content": finalText}
|
||||
if thinkingEnabled && finalThinking != "" {
|
||||
messageObj["reasoning_content"] = finalThinking
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected)
|
||||
messageObj["content"] = nil
|
||||
}
|
||||
promptTokens := util.EstimateTokens(finalPrompt)
|
||||
reasoningTokens := util.EstimateTokens(finalThinking)
|
||||
completionTokens := util.EstimateTokens(finalText)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": completionID,
|
||||
"object": "chat.completion",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]any{{"index": 0, "message": messageObj, "finish_reason": finishReason}},
|
||||
"usage": map[string]any{
|
||||
"prompt_tokens": promptTokens,
|
||||
"completion_tokens": reasoningTokens + completionTokens,
|
||||
"total_tokens": promptTokens + reasoningTokens + completionTokens,
|
||||
"completion_tokens_details": map[string]any{
|
||||
"reasoning_tokens": reasoningTokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
lines := make(chan []byte, 128)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
b := append([]byte{}, scanner.Bytes()...)
|
||||
lines <- b
|
||||
}
|
||||
close(lines)
|
||||
done <- scanner.Err()
|
||||
}()
|
||||
|
||||
created := time.Now().Unix()
|
||||
firstChunkSent := false
|
||||
bufferToolContent := len(toolNames) > 0
|
||||
currentType := "text"
|
||||
if thinkingEnabled {
|
||||
currentType = "thinking"
|
||||
}
|
||||
thinking := strings.Builder{}
|
||||
text := strings.Builder{}
|
||||
lastContent := time.Now()
|
||||
hasContent := false
|
||||
keepaliveTicker := time.NewTicker(time.Duration(deepseek.KeepAliveTimeout) * time.Second)
|
||||
defer keepaliveTicker.Stop()
|
||||
|
||||
sendChunk := func(v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
_, _ = w.Write([]byte("data: "))
|
||||
_, _ = w.Write(b)
|
||||
_, _ = w.Write([]byte("\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
sendDone := func() {
|
||||
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
finalize := func(finishReason string) {
|
||||
finalThinking := thinking.String()
|
||||
finalText := text.String()
|
||||
detected := util.ParseToolCalls(finalText, toolNames)
|
||||
if len(detected) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
delta := map[string]any{
|
||||
"tool_calls": util.FormatOpenAIToolCalls(detected),
|
||||
}
|
||||
if !firstChunkSent {
|
||||
delta["role"] = "assistant"
|
||||
firstChunkSent = true
|
||||
}
|
||||
sendChunk(map[string]any{
|
||||
"id": completionID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]any{{"delta": delta, "index": 0}},
|
||||
})
|
||||
} else if bufferToolContent && strings.TrimSpace(finalText) != "" {
|
||||
delta := map[string]any{
|
||||
"content": finalText,
|
||||
}
|
||||
if !firstChunkSent {
|
||||
delta["role"] = "assistant"
|
||||
firstChunkSent = true
|
||||
}
|
||||
sendChunk(map[string]any{
|
||||
"id": completionID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]any{{"delta": delta, "index": 0}},
|
||||
})
|
||||
}
|
||||
promptTokens := util.EstimateTokens(finalPrompt)
|
||||
reasoningTokens := util.EstimateTokens(finalThinking)
|
||||
completionTokens := util.EstimateTokens(finalText)
|
||||
sendChunk(map[string]any{
|
||||
"id": completionID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": []map[string]any{{"delta": map[string]any{}, "index": 0, "finish_reason": finishReason}},
|
||||
"usage": map[string]any{
|
||||
"prompt_tokens": promptTokens,
|
||||
"completion_tokens": reasoningTokens + completionTokens,
|
||||
"total_tokens": promptTokens + reasoningTokens + completionTokens,
|
||||
"completion_tokens_details": map[string]any{
|
||||
"reasoning_tokens": reasoningTokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
sendDone()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-keepaliveTicker.C:
|
||||
if hasContent && time.Since(lastContent) > time.Duration(deepseek.StreamIdleTimeout)*time.Second {
|
||||
finalize("stop")
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(": keep-alive\n\n"))
|
||||
flusher.Flush()
|
||||
case line, ok := <-lines:
|
||||
if !ok {
|
||||
// Ensure scanner completion is observed only after all queued
|
||||
// SSE lines are drained, avoiding early finalize races.
|
||||
_ = <-done
|
||||
finalize("stop")
|
||||
return
|
||||
}
|
||||
chunk, doneSignal, parsed := sse.ParseDeepSeekSSELine(line)
|
||||
if !parsed {
|
||||
continue
|
||||
}
|
||||
if doneSignal {
|
||||
finalize("stop")
|
||||
return
|
||||
}
|
||||
if _, hasErr := chunk["error"]; hasErr || chunk["code"] == "content_filter" {
|
||||
finalize("content_filter")
|
||||
return
|
||||
}
|
||||
parts, finished, newType := sse.ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||
currentType = newType
|
||||
if finished {
|
||||
finalize("stop")
|
||||
return
|
||||
}
|
||||
newChoices := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if searchEnabled && sse.IsCitation(p.Text) {
|
||||
continue
|
||||
}
|
||||
if p.Text == "" {
|
||||
continue
|
||||
}
|
||||
hasContent = true
|
||||
lastContent = time.Now()
|
||||
delta := map[string]any{}
|
||||
if !firstChunkSent {
|
||||
delta["role"] = "assistant"
|
||||
firstChunkSent = true
|
||||
}
|
||||
if p.Type == "thinking" {
|
||||
if thinkingEnabled {
|
||||
thinking.WriteString(p.Text)
|
||||
delta["reasoning_content"] = p.Text
|
||||
}
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
if !bufferToolContent {
|
||||
delta["content"] = p.Text
|
||||
}
|
||||
}
|
||||
if len(delta) > 0 {
|
||||
newChoices = append(newChoices, map[string]any{"delta": delta, "index": 0})
|
||||
}
|
||||
}
|
||||
if len(newChoices) > 0 {
|
||||
sendChunk(map[string]any{
|
||||
"id": completionID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": newChoices,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMessages(raw []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func injectToolPrompt(messages []map[string]any, tools []any) ([]map[string]any, []string) {
|
||||
toolSchemas := make([]string, 0, len(tools))
|
||||
names := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
tool, ok := t.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := tool["function"].(map[string]any)
|
||||
if len(fn) == 0 {
|
||||
fn = tool
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
desc, _ := fn["description"].(string)
|
||||
schema, _ := fn["parameters"].(map[string]any)
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
names = append(names, name)
|
||||
if desc == "" {
|
||||
desc = "No description available"
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, string(b)))
|
||||
}
|
||||
if len(toolSchemas) == 0 {
|
||||
return messages, names
|
||||
}
|
||||
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nIMPORTANT: If calling tools, output ONLY the JSON. The response must start with { and end with }"
|
||||
|
||||
for i := range messages {
|
||||
if messages[i]["role"] == "system" {
|
||||
old, _ := messages[i]["content"].(string)
|
||||
messages[i]["content"] = strings.TrimSpace(old + "\n\n" + toolPrompt)
|
||||
return messages, names
|
||||
}
|
||||
}
|
||||
messages = append([]map[string]any{{"role": "system", "content": toolPrompt}}, messages...)
|
||||
return messages, names
|
||||
}
|
||||
|
||||
func toBool(v any) bool {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeOpenAIError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": message,
|
||||
"type": openAIErrorType(status),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func openAIErrorType(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest:
|
||||
return "invalid_request_error"
|
||||
case http.StatusUnauthorized:
|
||||
return "authentication_error"
|
||||
case http.StatusForbidden:
|
||||
return "permission_error"
|
||||
case http.StatusTooManyRequests:
|
||||
return "rate_limit_error"
|
||||
case http.StatusServiceUnavailable:
|
||||
return "service_unavailable_error"
|
||||
default:
|
||||
if status >= 500 {
|
||||
return "api_error"
|
||||
}
|
||||
return "invalid_request_error"
|
||||
}
|
||||
}
|
||||
283
internal/adapter/openai/handler_toolcall_test.go
Normal file
283
internal/adapter/openai/handler_toolcall_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeSSEHTTPResponse(lines ...string) *http.Response {
|
||||
body := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, body string) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||
t.Fatalf("decode json failed: %v, body=%s", err, body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSSEDataFrames(t *testing.T, body string) ([]map[string]any, bool) {
|
||||
t.Helper()
|
||||
lines := strings.Split(body, "\n")
|
||||
frames := make([]map[string]any, 0, len(lines))
|
||||
done := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
if payload == "[DONE]" {
|
||||
done = true
|
||||
continue
|
||||
}
|
||||
var frame map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &frame); err != nil {
|
||||
t.Fatalf("decode sse frame failed: %v, payload=%s", err, payload)
|
||||
}
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
return frames, done
|
||||
}
|
||||
|
||||
func streamHasRawToolJSONContent(frames []map[string]any) bool {
|
||||
for _, frame := range frames {
|
||||
choices, _ := frame["choices"].([]any)
|
||||
for _, item := range choices {
|
||||
choice, _ := item.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
content, _ := delta["content"].(string)
|
||||
if strings.Contains(content, `"tool_calls"`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func streamHasToolCallsDelta(frames []map[string]any) bool {
|
||||
for _, frame := range frames {
|
||||
choices, _ := frame["choices"].([]any)
|
||||
for _, item := range choices {
|
||||
choice, _ := item.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if _, ok := delta["tool_calls"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func streamFinishReason(frames []map[string]any) string {
|
||||
for _, frame := range frames {
|
||||
choices, _ := frame["choices"].([]any)
|
||||
for _, item := range choices {
|
||||
choice, _ := item.(map[string]any)
|
||||
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestHandleNonStreamToolCallInterceptsChatModel(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleNonStream(rec, context.Background(), resp, "cid1", "deepseek-chat", "prompt", false, false, []string{"search"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
if len(choices) != 1 {
|
||||
t.Fatalf("unexpected choices: %#v", out["choices"])
|
||||
}
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
}
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
if msg["content"] != nil {
|
||||
t.Fatalf("expected content nil, got %#v", msg["content"])
|
||||
}
|
||||
toolCalls, _ := msg["tool_calls"].([]any)
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %#v", msg["tool_calls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/thinking_content","v":"先想一下"}`,
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleNonStream(rec, context.Background(), resp, "cid2", "deepseek-reasoner", "prompt", true, false, []string{"search"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
if msg["reasoning_content"] != "先想一下" {
|
||||
t.Fatalf("expected reasoning_content, got %#v", msg["reasoning_content"])
|
||||
}
|
||||
if msg["content"] != nil {
|
||||
t.Fatalf("expected content nil, got %#v", msg["content"])
|
||||
}
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamUnknownToolStillIntercepted(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.handleNonStream(rec, context.Background(), resp, "cid2b", "deepseek-chat", "prompt", false, false, []string{"search"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", rec.Code)
|
||||
}
|
||||
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
}
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
if msg["content"] != nil {
|
||||
t.Fatalf("expected content nil, got %#v", msg["content"])
|
||||
}
|
||||
toolCalls, _ := msg["tool_calls"].([]any)
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %#v", msg["tool_calls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
|
||||
`data: {"p":"response/content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h.handleStream(rec, req, resp, "cid3", "deepseek-chat", "prompt", false, false, []string{"search"})
|
||||
|
||||
frames, done := parseSSEDataFrames(t, rec.Body.String())
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/thinking_content","v":"思考中"}`,
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h.handleStream(rec, req, resp, "cid4", "deepseek-reasoner", "prompt", true, false, []string{"search"})
|
||||
|
||||
frames, done := parseSSEDataFrames(t, rec.Body.String())
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
hasThinkingDelta := false
|
||||
for _, frame := range frames {
|
||||
choices, _ := frame["choices"].([]any)
|
||||
for _, item := range choices {
|
||||
choice, _ := item.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if _, ok := delta["reasoning_content"]; ok {
|
||||
hasThinkingDelta = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasThinkingDelta {
|
||||
t.Fatalf("expected reasoning_content delta in reasoner stream: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamUnknownToolStillIntercepted(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h.handleStream(rec, req, resp, "cid5", "deepseek-chat", "prompt", false, false, []string{"search"})
|
||||
|
||||
frames, done := parseSSEDataFrames(t, rec.Body.String())
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta, body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasRawToolJSONContent(frames) {
|
||||
t.Fatalf("raw tool_calls JSON leaked in content delta: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
890
internal/admin/handler.go
Normal file
890
internal/admin/handler.go
Normal file
@@ -0,0 +1,890 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/account"
|
||||
authn "ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store *config.Store
|
||||
Pool *account.Pool
|
||||
DS *deepseek.Client
|
||||
}
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
|
||||
r.Post("/login", h.login)
|
||||
r.Get("/verify", h.verify)
|
||||
r.Group(func(pr chi.Router) {
|
||||
pr.Use(h.requireAdmin)
|
||||
pr.Get("/vercel/config", h.getVercelConfig)
|
||||
pr.Get("/config", h.getConfig)
|
||||
pr.Post("/config", h.updateConfig)
|
||||
pr.Post("/keys", h.addKey)
|
||||
pr.Delete("/keys/{key}", h.deleteKey)
|
||||
pr.Get("/accounts", h.listAccounts)
|
||||
pr.Post("/accounts", h.addAccount)
|
||||
pr.Delete("/accounts/{identifier}", h.deleteAccount)
|
||||
pr.Get("/queue/status", h.queueStatus)
|
||||
pr.Post("/accounts/test", h.testSingleAccount)
|
||||
pr.Post("/accounts/test-all", h.testAllAccounts)
|
||||
pr.Post("/import", h.batchImport)
|
||||
pr.Post("/test", h.testAPI)
|
||||
pr.Post("/vercel/sync", h.syncVercel)
|
||||
pr.Get("/vercel/status", h.vercelStatus)
|
||||
pr.Get("/export", h.exportConfig)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := authn.VerifyAdminRequest(r); err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
adminKey, _ := req["admin_key"].(string)
|
||||
expireHours := intFrom(req["expire_hours"])
|
||||
if expireHours <= 0 {
|
||||
expireHours = 24
|
||||
}
|
||||
if adminKey != authn.AdminKey() {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
|
||||
return
|
||||
}
|
||||
token, err := authn.CreateJWT(expireHours)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "token": token, "expires_in": expireHours * 3600})
|
||||
}
|
||||
|
||||
func (h *Handler) verify(w http.ResponseWriter, r *http.Request) {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "No credentials provided"})
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(header[7:])
|
||||
payload, err := authn.VerifyJWT(token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
exp, _ := payload["exp"].(float64)
|
||||
remaining := int64(exp) - time.Now().Unix()
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"valid": true, "expires_at": int64(exp), "remaining_seconds": remaining})
|
||||
}
|
||||
|
||||
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "",
|
||||
"project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")),
|
||||
"team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := h.Store.Snapshot()
|
||||
safe := map[string]any{
|
||||
"keys": snap.Keys,
|
||||
"accounts": []map[string]any{},
|
||||
"claude_mapping": func() map[string]string {
|
||||
if len(snap.ClaudeMapping) > 0 {
|
||||
return snap.ClaudeMapping
|
||||
}
|
||||
return snap.ClaudeModelMap
|
||||
}(),
|
||||
}
|
||||
accounts := make([]map[string]any, 0, len(snap.Accounts))
|
||||
for _, acc := range snap.Accounts {
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
preview := ""
|
||||
if token != "" {
|
||||
if len(token) > 20 {
|
||||
preview = token[:20] + "..."
|
||||
} else {
|
||||
preview = token
|
||||
}
|
||||
}
|
||||
accounts = append(accounts, map[string]any{
|
||||
"email": acc.Email,
|
||||
"mobile": acc.Mobile,
|
||||
"has_password": strings.TrimSpace(acc.Password) != "",
|
||||
"has_token": token != "",
|
||||
"token_preview": preview,
|
||||
})
|
||||
}
|
||||
safe["accounts"] = accounts
|
||||
writeJSON(w, http.StatusOK, safe)
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
||||
return
|
||||
}
|
||||
old := h.Store.Snapshot()
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if keys, ok := toStringSlice(req["keys"]); ok {
|
||||
c.Keys = keys
|
||||
}
|
||||
if accountsRaw, ok := req["accounts"].([]any); ok {
|
||||
existing := map[string]config.Account{}
|
||||
for _, a := range old.Accounts {
|
||||
existing[a.Identifier()] = a
|
||||
}
|
||||
accounts := make([]config.Account, 0, len(accountsRaw))
|
||||
for _, item := range accountsRaw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
acc := toAccount(m)
|
||||
id := acc.Identifier()
|
||||
if prev, ok := existing[id]; ok {
|
||||
if strings.TrimSpace(acc.Password) == "" {
|
||||
acc.Password = prev.Password
|
||||
}
|
||||
if strings.TrimSpace(acc.Token) == "" {
|
||||
acc.Token = prev.Token
|
||||
}
|
||||
}
|
||||
accounts = append(accounts, acc)
|
||||
}
|
||||
c.Accounts = accounts
|
||||
}
|
||||
if m, ok := req["claude_mapping"].(map[string]any); ok {
|
||||
newMap := map[string]string{}
|
||||
for k, v := range m {
|
||||
newMap[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
c.ClaudeMapping = newMap
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "配置已更新"})
|
||||
}
|
||||
|
||||
func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
key, _ := req["key"].(string)
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Key 不能为空"})
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, k := range c.Keys {
|
||||
if k == key {
|
||||
return fmt.Errorf("Key 已存在")
|
||||
}
|
||||
}
|
||||
c.Keys = append(c.Keys, key)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := chi.URLParam(r, "key")
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, k := range c.Keys {
|
||||
if k == key {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("Key 不存在")
|
||||
}
|
||||
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)})
|
||||
}
|
||||
|
||||
func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
page := intFromQuery(r, "page", 1)
|
||||
pageSize := intFromQuery(r, "page_size", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 1
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
total := len(accounts)
|
||||
reverseAccounts(accounts)
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, acc := range accounts[start:end] {
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
preview := ""
|
||||
if token != "" {
|
||||
if len(token) > 20 {
|
||||
preview = token[:20] + "..."
|
||||
} else {
|
||||
preview = token
|
||||
}
|
||||
}
|
||||
items = append(items, map[string]any{"email": acc.Email, "mobile": acc.Mobile, "has_password": acc.Password != "", "has_token": token != "", "token_preview": preview})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
}
|
||||
|
||||
func (h *Handler) addAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
acc := toAccount(req)
|
||||
if acc.Identifier() == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 email 或 mobile"})
|
||||
return
|
||||
}
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, a := range c.Accounts {
|
||||
if acc.Email != "" && a.Email == acc.Email {
|
||||
return fmt.Errorf("邮箱已存在")
|
||||
}
|
||||
if acc.Mobile != "" && a.Mobile == acc.Mobile {
|
||||
return fmt.Errorf("手机号已存在")
|
||||
}
|
||||
}
|
||||
c.Accounts = append(c.Accounts, acc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
identifier := chi.URLParam(r, "identifier")
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
idx := -1
|
||||
for i, a := range c.Accounts {
|
||||
if a.Email == identifier || a.Mobile == identifier {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("账号不存在")
|
||||
}
|
||||
c.Accounts = append(c.Accounts[:idx], c.Accounts[idx+1:]...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_accounts": len(h.Store.Snapshot().Accounts)})
|
||||
}
|
||||
|
||||
func (h *Handler) queueStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.Pool.Status())
|
||||
}
|
||||
|
||||
func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(email 或 mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := h.Store.FindAccount(identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
message, _ := req["message"].(string)
|
||||
result := h.testAccount(r.Context(), acc, model, message)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
accounts := h.Store.Snapshot().Accounts
|
||||
if len(accounts) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
|
||||
return
|
||||
}
|
||||
results := make([]map[string]any, 0, len(accounts))
|
||||
success := 0
|
||||
for _, acc := range accounts {
|
||||
res := h.testAccount(r.Context(), acc, model, "")
|
||||
if ok, _ := res["success"].(bool); ok {
|
||||
success++
|
||||
}
|
||||
results = append(results, res)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
|
||||
}
|
||||
|
||||
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
|
||||
start := time.Now()
|
||||
result := map[string]any{"account": acc.Identifier(), "success": false, "response_time": 0, "message": "", "model": model}
|
||||
token := strings.TrimSpace(acc.Token)
|
||||
if token == "" {
|
||||
newToken, err := h.DS.Login(ctx, acc)
|
||||
if err != nil {
|
||||
result["message"] = "登录失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
|
||||
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
newToken, loginErr := h.DS.Login(ctx, acc)
|
||||
if loginErr != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
token = newToken
|
||||
authCtx.DeepSeekToken = token
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "创建会话失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
result["success"] = true
|
||||
result["message"] = "API 测试成功(仅会话创建)"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
thinking, search, ok := config.GetModelConfig(model)
|
||||
if !ok {
|
||||
thinking, search = false, false
|
||||
}
|
||||
pow, err := h.DS.GetPow(ctx, authCtx, 1)
|
||||
if err != nil {
|
||||
result["message"] = "获取 PoW 失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
payload := map[string]any{"chat_session_id": sessionID, "prompt": "<|User|>" + message, "ref_file_ids": []any{}, "thinking_enabled": thinking, "search_enabled": search}
|
||||
resp, err := h.DS.CallCompletion(ctx, authCtx, payload, pow, 1)
|
||||
if err != nil {
|
||||
result["message"] = "请求失败: " + err.Error()
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
text := strings.Builder{}
|
||||
think := strings.Builder{}
|
||||
currentType := "text"
|
||||
if thinking {
|
||||
currentType = "thinking"
|
||||
}
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
chunk, done, parsed := sse.ParseDeepSeekSSELine(scanner.Bytes())
|
||||
if !parsed {
|
||||
continue
|
||||
}
|
||||
if done {
|
||||
break
|
||||
}
|
||||
parts, finished, newType := sse.ParseSSEChunkForContent(chunk, thinking, currentType)
|
||||
currentType = newType
|
||||
if finished {
|
||||
break
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p.Type == "thinking" {
|
||||
think.WriteString(p.Text)
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
result["success"] = true
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
if text.Len() > 0 {
|
||||
result["message"] = text.String()
|
||||
} else {
|
||||
result["message"] = "(无回复内容)"
|
||||
}
|
||||
if think.Len() > 0 {
|
||||
result["thinking"] = think.String()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Handler) batchImport(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "无效的 JSON 格式"})
|
||||
return
|
||||
}
|
||||
importedKeys, importedAccounts := 0, 0
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
if keys, ok := req["keys"].([]any); ok {
|
||||
existing := map[string]bool{}
|
||||
for _, k := range c.Keys {
|
||||
existing[k] = true
|
||||
}
|
||||
for _, k := range keys {
|
||||
key := strings.TrimSpace(fmt.Sprintf("%v", k))
|
||||
if key == "" || existing[key] {
|
||||
continue
|
||||
}
|
||||
c.Keys = append(c.Keys, key)
|
||||
existing[key] = true
|
||||
importedKeys++
|
||||
}
|
||||
}
|
||||
if accounts, ok := req["accounts"].([]any); ok {
|
||||
existing := map[string]bool{}
|
||||
for _, a := range c.Accounts {
|
||||
existing[a.Identifier()] = true
|
||||
}
|
||||
for _, item := range accounts {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
acc := toAccount(m)
|
||||
id := acc.Identifier()
|
||||
if id == "" || existing[id] {
|
||||
continue
|
||||
}
|
||||
c.Accounts = append(c.Accounts, acc)
|
||||
existing[id] = true
|
||||
importedAccounts++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.Pool.Reset()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "imported_keys": importedKeys, "imported_accounts": importedAccounts})
|
||||
}
|
||||
|
||||
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
model, _ := req["model"].(string)
|
||||
message, _ := req["message"].(string)
|
||||
apiKey, _ := req["api_key"].(string)
|
||||
if model == "" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
if message == "" {
|
||||
message = "你好"
|
||||
}
|
||||
if apiKey == "" {
|
||||
keys := h.Store.Snapshot().Keys
|
||||
if len(keys) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
|
||||
return
|
||||
}
|
||||
apiKey = keys[0]
|
||||
}
|
||||
host := r.Host
|
||||
scheme := "http"
|
||||
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
|
||||
b, _ := json.Marshal(payload)
|
||||
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
|
||||
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
||||
return
|
||||
}
|
||||
vercelToken, _ := req["vercel_token"].(string)
|
||||
projectID, _ := req["project_id"].(string)
|
||||
teamID, _ := req["team_id"].(string)
|
||||
autoValidate := true
|
||||
if v, ok := req["auto_validate"].(bool); ok {
|
||||
autoValidate = v
|
||||
}
|
||||
saveCreds := true
|
||||
if v, ok := req["save_credentials"].(bool); ok {
|
||||
saveCreds = v
|
||||
}
|
||||
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
|
||||
if usePreconfig {
|
||||
vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID"))
|
||||
}
|
||||
if strings.TrimSpace(teamID) == "" {
|
||||
teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))
|
||||
}
|
||||
if vercelToken == "" || projectID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要 Vercel Token 和 Project ID"})
|
||||
return
|
||||
}
|
||||
validated, failed := 0, []string{}
|
||||
if autoValidate {
|
||||
for _, acc := range h.Store.Snapshot().Accounts {
|
||||
if strings.TrimSpace(acc.Token) != "" {
|
||||
continue
|
||||
}
|
||||
token, err := h.DS.Login(r.Context(), acc)
|
||||
if err != nil {
|
||||
failed = append(failed, acc.Identifier())
|
||||
} else {
|
||||
validated++
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
cfgJSON, _, err := h.Store.ExportJSONAndBase64()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
cfgB64 := base64.StdEncoding.EncodeToString([]byte(cfgJSON))
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
params := url.Values{}
|
||||
if teamID != "" {
|
||||
params.Set("teamId", teamID)
|
||||
}
|
||||
headers := map[string]string{"Authorization": "Bearer " + vercelToken}
|
||||
envResp, status, err := vercelRequest(r.Context(), client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID+"/env", params, headers, nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "获取环境变量失败"})
|
||||
return
|
||||
}
|
||||
envs, _ := envResp["envs"].([]any)
|
||||
existingEnvID := findEnvID(envs, "DS2API_CONFIG_JSON")
|
||||
if existingEnvID != "" {
|
||||
_, status, err = vercelRequest(r.Context(), client, http.MethodPatch, "https://api.vercel.com/v9/projects/"+projectID+"/env/"+existingEnvID, params, headers, map[string]any{"value": cfgB64})
|
||||
} else {
|
||||
_, status, err = vercelRequest(r.Context(), client, http.MethodPost, "https://api.vercel.com/v10/projects/"+projectID+"/env", params, headers, map[string]any{"key": "DS2API_CONFIG_JSON", "value": cfgB64, "type": "encrypted", "target": []string{"production", "preview"}})
|
||||
}
|
||||
if err != nil || (status != http.StatusOK && status != http.StatusCreated) {
|
||||
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "更新环境变量失败"})
|
||||
return
|
||||
}
|
||||
savedCreds := []string{}
|
||||
if saveCreds && !usePreconfig {
|
||||
creds := [][2]string{{"VERCEL_TOKEN", vercelToken}, {"VERCEL_PROJECT_ID", projectID}}
|
||||
if teamID != "" {
|
||||
creds = append(creds, [2]string{"VERCEL_TEAM_ID", teamID})
|
||||
}
|
||||
for _, kv := range creds {
|
||||
id := findEnvID(envs, kv[0])
|
||||
if id != "" {
|
||||
_, status, _ = vercelRequest(r.Context(), client, http.MethodPatch, "https://api.vercel.com/v9/projects/"+projectID+"/env/"+id, params, headers, map[string]any{"value": kv[1]})
|
||||
} else {
|
||||
_, status, _ = vercelRequest(r.Context(), client, http.MethodPost, "https://api.vercel.com/v10/projects/"+projectID+"/env", params, headers, map[string]any{"key": kv[0], "value": kv[1], "type": "encrypted", "target": []string{"production", "preview"}})
|
||||
}
|
||||
if status == http.StatusOK || status == http.StatusCreated {
|
||||
savedCreds = append(savedCreds, kv[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
projectResp, status, _ := vercelRequest(r.Context(), client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
|
||||
manual := true
|
||||
deployURL := ""
|
||||
if status == http.StatusOK {
|
||||
if link, ok := projectResp["link"].(map[string]any); ok {
|
||||
if linkType, _ := link["type"].(string); linkType == "github" {
|
||||
repoID := intFrom(link["repoId"])
|
||||
ref, _ := link["productionBranch"].(string)
|
||||
if ref == "" {
|
||||
ref = "main"
|
||||
}
|
||||
depResp, depStatus, _ := vercelRequest(r.Context(), client, http.MethodPost, "https://api.vercel.com/v13/deployments", params, headers, map[string]any{"name": projectID, "project": projectID, "target": "production", "gitSource": map[string]any{"type": "github", "repoId": repoID, "ref": ref}})
|
||||
if depStatus == http.StatusOK || depStatus == http.StatusCreated {
|
||||
deployURL, _ = depResp["url"].(string)
|
||||
manual = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix())
|
||||
result := map[string]any{"success": true, "validated_accounts": validated}
|
||||
if manual {
|
||||
result["message"] = "配置已同步到 Vercel,请手动触发重新部署"
|
||||
result["manual_deploy_required"] = true
|
||||
} else {
|
||||
result["message"] = "配置已同步,正在重新部署..."
|
||||
result["deployment_url"] = deployURL
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
result["failed_accounts"] = failed
|
||||
}
|
||||
if len(savedCreds) > 0 {
|
||||
result["saved_credentials"] = savedCreds
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := h.Store.Snapshot()
|
||||
current := h.computeSyncHash()
|
||||
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
|
||||
writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""})
|
||||
}
|
||||
|
||||
func (h *Handler) exportConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonStr, b64, err := h.Store.ExportJSONAndBase64()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"json": jsonStr, "base64": b64})
|
||||
}
|
||||
|
||||
func (h *Handler) computeSyncHash() string {
|
||||
snap := h.Store.Snapshot()
|
||||
syncable := map[string]any{"keys": snap.Keys, "accounts": []map[string]any{}}
|
||||
accounts := make([]map[string]any, 0, len(snap.Accounts))
|
||||
for _, a := range snap.Accounts {
|
||||
m := map[string]any{}
|
||||
if a.Email != "" {
|
||||
m["email"] = a.Email
|
||||
}
|
||||
if a.Mobile != "" {
|
||||
m["mobile"] = a.Mobile
|
||||
}
|
||||
if a.Password != "" {
|
||||
m["password"] = a.Password
|
||||
}
|
||||
accounts = append(accounts, m)
|
||||
}
|
||||
sort.Slice(accounts, func(i, j int) bool {
|
||||
ai := fmt.Sprintf("%v%v", accounts[i]["email"], accounts[i]["mobile"])
|
||||
aj := fmt.Sprintf("%v%v", accounts[j]["email"], accounts[j]["mobile"])
|
||||
return ai < aj
|
||||
})
|
||||
syncable["accounts"] = accounts
|
||||
b, _ := json.Marshal(syncable)
|
||||
sum := md5.Sum(b)
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
parsed := map[string]any{}
|
||||
_ = json.Unmarshal(b, &parsed)
|
||||
if len(parsed) == 0 {
|
||||
parsed["raw"] = string(b)
|
||||
}
|
||||
return parsed, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func findEnvID(envs []any, key string) string {
|
||||
for _, item := range envs {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if k, _ := m["key"].(string); k == key {
|
||||
id, _ := m["id"].(string)
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func reverseAccounts(a []config.Account) {
|
||||
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
}
|
||||
|
||||
func intFromQuery(r *http.Request, key string, d int) int {
|
||||
v := r.URL.Query().Get(key)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func intFrom(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nilIfZero(v int64) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toStringSlice(v any) ([]string, bool) {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
out = append(out, strings.TrimSpace(fmt.Sprintf("%v", item)))
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func toAccount(m map[string]any) config.Account {
|
||||
return config.Account{Email: strings.TrimSpace(fmt.Sprintf("%v", m["email"])), Mobile: strings.TrimSpace(fmt.Sprintf("%v", m["mobile"])), Password: strings.TrimSpace(fmt.Sprintf("%v", m["password"])), Token: strings.TrimSpace(fmt.Sprintf("%v", m["token"]))}
|
||||
}
|
||||
|
||||
func statusOr(v int, d int) int {
|
||||
if v == 0 {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
113
internal/auth/admin.go
Normal file
113
internal/auth/admin.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AdminKey() string {
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func jwtSecret() string {
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_JWT_SECRET")); v != "" {
|
||||
return v
|
||||
}
|
||||
return AdminKey()
|
||||
}
|
||||
|
||||
func jwtExpireHours() int {
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_JWT_EXPIRE_HOURS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 24
|
||||
}
|
||||
|
||||
func CreateJWT(expireHours int) (string, error) {
|
||||
if expireHours <= 0 {
|
||||
expireHours = jwtExpireHours()
|
||||
}
|
||||
header := map[string]any{"alg": "HS256", "typ": "JWT"}
|
||||
payload := map[string]any{"iat": time.Now().Unix(), "exp": time.Now().Add(time.Duration(expireHours) * time.Hour).Unix(), "role": "admin"}
|
||||
h, _ := json.Marshal(header)
|
||||
p, _ := json.Marshal(payload)
|
||||
headerB64 := rawB64Encode(h)
|
||||
payloadB64 := rawB64Encode(p)
|
||||
msg := headerB64 + "." + payloadB64
|
||||
sig := signHS256(msg)
|
||||
return msg + "." + rawB64Encode(sig), nil
|
||||
}
|
||||
|
||||
func VerifyJWT(token string) (map[string]any, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("invalid token format")
|
||||
}
|
||||
msg := parts[0] + "." + parts[1]
|
||||
expected := signHS256(msg)
|
||||
actual, err := rawB64Decode(parts[2])
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid signature")
|
||||
}
|
||||
if !hmac.Equal(expected, actual) {
|
||||
return nil, errors.New("invalid signature")
|
||||
}
|
||||
payloadBytes, err := rawB64Decode(parts[1])
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid payload")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
return nil, errors.New("invalid payload")
|
||||
}
|
||||
exp, _ := payload["exp"].(float64)
|
||||
if int64(exp) < time.Now().Unix() {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func VerifyAdminRequest(r *http.Request) error {
|
||||
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
if token == "" {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
if token == AdminKey() {
|
||||
return nil
|
||||
}
|
||||
if _, err := VerifyJWT(token); err == nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
func signHS256(msg string) []byte {
|
||||
h := hmac.New(sha256.New, []byte(jwtSecret()))
|
||||
_, _ = h.Write([]byte(msg))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func rawB64Encode(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func rawB64Decode(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
29
internal/auth/admin_test.go
Normal file
29
internal/auth/admin_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJWTCreateVerify(t *testing.T) {
|
||||
token, err := CreateJWT(1)
|
||||
if err != nil {
|
||||
t.Fatalf("create jwt failed: %v", err)
|
||||
}
|
||||
payload, err := VerifyJWT(token)
|
||||
if err != nil {
|
||||
t.Fatalf("verify jwt failed: %v", err)
|
||||
}
|
||||
if payload["role"] != "admin" {
|
||||
t.Fatalf("unexpected payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAdminRequest(t *testing.T) {
|
||||
token, _ := CreateJWT(1)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/admin/config", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if err := VerifyAdminRequest(req); err != nil {
|
||||
t.Fatalf("expected token accepted: %v", err)
|
||||
}
|
||||
}
|
||||
160
internal/auth/request.go
Normal file
160
internal/auth/request.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const authCtxKey ctxKey = "auth_context"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized: missing auth token")
|
||||
ErrNoAccount = errors.New("no accounts configured or all accounts are busy")
|
||||
)
|
||||
|
||||
type RequestAuth struct {
|
||||
UseConfigToken bool
|
||||
DeepSeekToken string
|
||||
AccountID string
|
||||
Account config.Account
|
||||
TriedAccounts map[string]bool
|
||||
resolver *Resolver
|
||||
}
|
||||
|
||||
type LoginFunc func(ctx context.Context, acc config.Account) (string, error)
|
||||
|
||||
type Resolver struct {
|
||||
Store *config.Store
|
||||
Pool *account.Pool
|
||||
Login LoginFunc
|
||||
}
|
||||
|
||||
func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver {
|
||||
return &Resolver{Store: store, Pool: pool, Login: login}
|
||||
}
|
||||
|
||||
func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
|
||||
callerKey := extractCallerToken(req)
|
||||
if callerKey == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
ctx := req.Context()
|
||||
if !r.Store.HasAPIKey(callerKey) {
|
||||
return &RequestAuth{UseConfigToken: false, DeepSeekToken: callerKey, resolver: r, TriedAccounts: map[string]bool{}}, nil
|
||||
}
|
||||
target := strings.TrimSpace(req.Header.Get("X-Ds2-Target-Account"))
|
||||
acc, ok := r.Pool.Acquire(target, nil)
|
||||
if !ok {
|
||||
return nil, ErrNoAccount
|
||||
}
|
||||
a := &RequestAuth{
|
||||
UseConfigToken: true,
|
||||
AccountID: acc.Identifier(),
|
||||
Account: acc,
|
||||
TriedAccounts: map[string]bool{},
|
||||
resolver: r,
|
||||
}
|
||||
if acc.Token == "" {
|
||||
if err := r.loginAndPersist(ctx, a); err != nil {
|
||||
r.Pool.Release(a.AccountID)
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
a.DeepSeekToken = acc.Token
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func WithAuth(ctx context.Context, a *RequestAuth) context.Context {
|
||||
return context.WithValue(ctx, authCtxKey, a)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (*RequestAuth, bool) {
|
||||
v := ctx.Value(authCtxKey)
|
||||
a, ok := v.(*RequestAuth)
|
||||
return a, ok
|
||||
}
|
||||
|
||||
func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
|
||||
token, err := r.Login(ctx, a.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Account.Token = token
|
||||
a.DeepSeekToken = token
|
||||
return r.Store.UpdateAccountToken(a.AccountID, token)
|
||||
}
|
||||
|
||||
func (r *Resolver) RefreshToken(ctx context.Context, a *RequestAuth) bool {
|
||||
if !a.UseConfigToken || a.AccountID == "" {
|
||||
return false
|
||||
}
|
||||
_ = r.Store.UpdateAccountToken(a.AccountID, "")
|
||||
a.Account.Token = ""
|
||||
if err := r.loginAndPersist(ctx, a); err != nil {
|
||||
config.Logger.Error("[refresh_token] failed", "account", a.AccountID, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
|
||||
if !a.UseConfigToken || a.AccountID == "" {
|
||||
return
|
||||
}
|
||||
a.Account.Token = ""
|
||||
a.DeepSeekToken = ""
|
||||
_ = r.Store.UpdateAccountToken(a.AccountID, "")
|
||||
}
|
||||
|
||||
func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
|
||||
if !a.UseConfigToken {
|
||||
return false
|
||||
}
|
||||
if a.TriedAccounts == nil {
|
||||
a.TriedAccounts = map[string]bool{}
|
||||
}
|
||||
if a.AccountID != "" {
|
||||
a.TriedAccounts[a.AccountID] = true
|
||||
r.Pool.Release(a.AccountID)
|
||||
}
|
||||
acc, ok := r.Pool.Acquire("", a.TriedAccounts)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
a.Account = acc
|
||||
a.AccountID = acc.Identifier()
|
||||
if acc.Token == "" {
|
||||
if err := r.loginAndPersist(ctx, a); err != nil {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
a.DeepSeekToken = acc.Token
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Resolver) Release(a *RequestAuth) {
|
||||
if a == nil || !a.UseConfigToken || a.AccountID == "" {
|
||||
return
|
||||
}
|
||||
r.Pool.Release(a.AccountID)
|
||||
}
|
||||
|
||||
func extractCallerToken(req *http.Request) string {
|
||||
authHeader := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(req.Header.Get("x-api-key"))
|
||||
}
|
||||
74
internal/auth/request_test.go
Normal file
74
internal/auth/request_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func newTestResolver(t *testing.T) *Resolver {
|
||||
t.Helper()
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["managed-key"],
|
||||
"accounts":[{"email":"acc@example.com","password":"pwd","token":"account-token"}]
|
||||
}`)
|
||||
store := config.LoadStore()
|
||||
pool := account.NewPool(store)
|
||||
return NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
|
||||
return "fresh-token", nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetermineWithXAPIKeyUsesDirectToken(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
req.Header.Set("x-api-key", "direct-token")
|
||||
|
||||
auth, err := r.Determine(req)
|
||||
if err != nil {
|
||||
t.Fatalf("determine failed: %v", err)
|
||||
}
|
||||
if auth.UseConfigToken {
|
||||
t.Fatalf("expected direct token mode")
|
||||
}
|
||||
if auth.DeepSeekToken != "direct-token" {
|
||||
t.Fatalf("unexpected token: %q", auth.DeepSeekToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
req.Header.Set("x-api-key", "managed-key")
|
||||
|
||||
auth, err := r.Determine(req)
|
||||
if err != nil {
|
||||
t.Fatalf("determine failed: %v", err)
|
||||
}
|
||||
defer r.Release(auth)
|
||||
if !auth.UseConfigToken {
|
||||
t.Fatalf("expected managed key mode")
|
||||
}
|
||||
if auth.AccountID != "acc@example.com" {
|
||||
t.Fatalf("unexpected account id: %q", auth.AccountID)
|
||||
}
|
||||
if auth.DeepSeekToken != "account-token" {
|
||||
t.Fatalf("unexpected account token: %q", auth.DeepSeekToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineMissingToken(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
_, err := r.Determine(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
360
internal/config/config.go
Normal file
360
internal/config/config.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var Logger = newLogger()
|
||||
|
||||
func newLogger() *slog.Logger {
|
||||
level := new(slog.LevelVar)
|
||||
switch strings.ToUpper(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) {
|
||||
case "DEBUG":
|
||||
level.Set(slog.LevelDebug)
|
||||
case "WARN":
|
||||
level.Set(slog.LevelWarn)
|
||||
case "ERROR":
|
||||
level.Set(slog.LevelError)
|
||||
default:
|
||||
level.Set(slog.LevelInfo)
|
||||
}
|
||||
h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
||||
return slog.New(h)
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
func (a Account) Identifier() string {
|
||||
if strings.TrimSpace(a.Email) != "" {
|
||||
return strings.TrimSpace(a.Email)
|
||||
}
|
||||
return strings.TrimSpace(a.Mobile)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Accounts []Account `json:"accounts,omitempty"`
|
||||
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
|
||||
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
|
||||
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
||||
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
||||
AdditionalFields map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
func (c Config) MarshalJSON() ([]byte, error) {
|
||||
m := map[string]any{}
|
||||
for k, v := range c.AdditionalFields {
|
||||
m[k] = v
|
||||
}
|
||||
if len(c.Keys) > 0 {
|
||||
m["keys"] = c.Keys
|
||||
}
|
||||
if len(c.Accounts) > 0 {
|
||||
m["accounts"] = c.Accounts
|
||||
}
|
||||
if len(c.ClaudeMapping) > 0 {
|
||||
m["claude_mapping"] = c.ClaudeMapping
|
||||
}
|
||||
if len(c.ClaudeModelMap) > 0 {
|
||||
m["claude_model_mapping"] = c.ClaudeModelMap
|
||||
}
|
||||
if c.VercelSyncHash != "" {
|
||||
m["_vercel_sync_hash"] = c.VercelSyncHash
|
||||
}
|
||||
if c.VercelSyncTime != 0 {
|
||||
m["_vercel_sync_time"] = c.VercelSyncTime
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
raw := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
c.AdditionalFields = map[string]any{}
|
||||
for k, v := range raw {
|
||||
switch k {
|
||||
case "keys":
|
||||
_ = json.Unmarshal(v, &c.Keys)
|
||||
case "accounts":
|
||||
_ = json.Unmarshal(v, &c.Accounts)
|
||||
case "claude_mapping":
|
||||
_ = json.Unmarshal(v, &c.ClaudeMapping)
|
||||
case "claude_model_mapping":
|
||||
_ = json.Unmarshal(v, &c.ClaudeModelMap)
|
||||
case "_vercel_sync_hash":
|
||||
_ = json.Unmarshal(v, &c.VercelSyncHash)
|
||||
case "_vercel_sync_time":
|
||||
_ = json.Unmarshal(v, &c.VercelSyncTime)
|
||||
default:
|
||||
var anyVal any
|
||||
if err := json.Unmarshal(v, &anyVal); err == nil {
|
||||
c.AdditionalFields[k] = anyVal
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Clone() Config {
|
||||
clone := Config{
|
||||
Keys: slices.Clone(c.Keys),
|
||||
Accounts: slices.Clone(c.Accounts),
|
||||
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
|
||||
ClaudeModelMap: cloneStringMap(c.ClaudeModelMap),
|
||||
VercelSyncHash: c.VercelSyncHash,
|
||||
VercelSyncTime: c.VercelSyncTime,
|
||||
AdditionalFields: map[string]any{},
|
||||
}
|
||||
for k, v := range c.AdditionalFields {
|
||||
clone.AdditionalFields[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
cfg Config
|
||||
path string
|
||||
fromEnv bool
|
||||
}
|
||||
|
||||
func BaseDir() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func IsVercel() bool {
|
||||
return strings.TrimSpace(os.Getenv("VERCEL")) != "" || strings.TrimSpace(os.Getenv("NOW_REGION")) != ""
|
||||
}
|
||||
|
||||
func ResolvePath(envKey, defaultRel string) string {
|
||||
raw := strings.TrimSpace(os.Getenv(envKey))
|
||||
if raw != "" {
|
||||
if filepath.IsAbs(raw) {
|
||||
return raw
|
||||
}
|
||||
return filepath.Join(BaseDir(), raw)
|
||||
}
|
||||
return filepath.Join(BaseDir(), defaultRel)
|
||||
}
|
||||
|
||||
func ConfigPath() string {
|
||||
return ResolvePath("DS2API_CONFIG_PATH", "config.json")
|
||||
}
|
||||
|
||||
func WASMPath() string {
|
||||
return ResolvePath("DS2API_WASM_PATH", "sha3_wasm_bg.7b9ca65ddd.wasm")
|
||||
}
|
||||
|
||||
func StaticAdminDir() string {
|
||||
return ResolvePath("DS2API_STATIC_ADMIN_DIR", "static/admin")
|
||||
}
|
||||
|
||||
func LoadStore() *Store {
|
||||
cfg, fromEnv, err := loadConfig()
|
||||
if err != nil {
|
||||
Logger.Warn("[config] load failed", "error", err)
|
||||
}
|
||||
if len(cfg.Keys) == 0 && len(cfg.Accounts) == 0 {
|
||||
Logger.Warn("[config] empty config loaded")
|
||||
}
|
||||
return &Store{cfg: cfg, path: ConfigPath(), fromEnv: fromEnv}
|
||||
}
|
||||
|
||||
func loadConfig() (Config, bool, error) {
|
||||
rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON"))
|
||||
if rawCfg == "" {
|
||||
rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON"))
|
||||
}
|
||||
if rawCfg != "" {
|
||||
cfg, err := parseConfigString(rawCfg)
|
||||
return cfg, true, err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(ConfigPath())
|
||||
if err != nil {
|
||||
return Config{}, false, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return Config{}, false, err
|
||||
}
|
||||
return cfg, false, nil
|
||||
}
|
||||
|
||||
func parseConfigString(raw string) (Config, error) {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.Clone()
|
||||
}
|
||||
|
||||
func (s *Store) HasAPIKey(k string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, key := range s.cfg.Keys {
|
||||
if key == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) Keys() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return slices.Clone(s.cfg.Keys)
|
||||
}
|
||||
|
||||
func (s *Store) Accounts() []Account {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return slices.Clone(s.cfg.Accounts)
|
||||
}
|
||||
|
||||
func (s *Store) FindAccount(identifier string) (Account, bool) {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, acc := range s.cfg.Accounts {
|
||||
if acc.Identifier() == identifier {
|
||||
return acc, true
|
||||
}
|
||||
}
|
||||
return Account{}, false
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAccountToken(identifier, token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.cfg.Accounts {
|
||||
if s.cfg.Accounts[i].Identifier() == identifier {
|
||||
s.cfg.Accounts[i].Token = token
|
||||
return s.saveLocked()
|
||||
}
|
||||
}
|
||||
return errors.New("account not found")
|
||||
}
|
||||
|
||||
func (s *Store) Replace(cfg Config) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cfg = cfg.Clone()
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) Update(mutator func(*Config) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cfg := s.cfg.Clone()
|
||||
if err := mutator(&cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.cfg = cfg
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) Save() error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.fromEnv {
|
||||
Logger.Info("[save_config] source from env, skip write")
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
if s.fromEnv {
|
||||
Logger.Info("[save_config] source from env, skip write")
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) IsEnvBacked() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.fromEnv
|
||||
}
|
||||
|
||||
func (s *Store) SetVercelSync(hash string, ts int64) error {
|
||||
return s.Update(func(c *Config) error {
|
||||
c.VercelSyncHash = hash
|
||||
c.VercelSyncTime = ts
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) ExportJSONAndBase64() (string, string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
b, err := json.Marshal(s.cfg)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(b), base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *Store) ClaudeMapping() map[string]string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.cfg.ClaudeModelMap) > 0 {
|
||||
return cloneStringMap(s.cfg.ClaudeModelMap)
|
||||
}
|
||||
if len(s.cfg.ClaudeMapping) > 0 {
|
||||
return cloneStringMap(s.cfg.ClaudeMapping)
|
||||
}
|
||||
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}
|
||||
}
|
||||
55
internal/config/models.go
Normal file
55
internal/config/models.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package config
|
||||
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Permission []any `json:"permission,omitempty"`
|
||||
}
|
||||
|
||||
var DeepSeekModels = []ModelInfo{
|
||||
{ID: "deepseek-chat", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-reasoner", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-chat-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-reasoner-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
}
|
||||
|
||||
var ClaudeModels = []ModelInfo{
|
||||
{ID: "claude-sonnet-4-20250514", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
|
||||
{ID: "claude-sonnet-4-20250514-fast", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
|
||||
{ID: "claude-sonnet-4-20250514-slow", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
|
||||
}
|
||||
|
||||
func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
|
||||
switch lower(model) {
|
||||
case "deepseek-chat":
|
||||
return false, false, true
|
||||
case "deepseek-reasoner":
|
||||
return true, false, true
|
||||
case "deepseek-chat-search":
|
||||
return false, true, true
|
||||
case "deepseek-reasoner-search":
|
||||
return true, true, true
|
||||
default:
|
||||
return false, false, false
|
||||
}
|
||||
}
|
||||
|
||||
func lower(s string) string {
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + 32
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func OpenAIModelsResponse() map[string]any {
|
||||
return map[string]any{"object": "list", "data": DeepSeekModels}
|
||||
}
|
||||
|
||||
func ClaudeModelsResponse() map[string]any {
|
||||
return map[string]any{"object": "list", "data": ClaudeModels}
|
||||
}
|
||||
342
internal/deepseek/client.go
Normal file
342
internal/deepseek/client.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
trans "ds2api/internal/deepseek/transport"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Store *config.Store
|
||||
Auth *auth.Resolver
|
||||
regular trans.Doer
|
||||
stream trans.Doer
|
||||
fallback *http.Client
|
||||
fallbackS *http.Client
|
||||
powSolver *PowSolver
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
|
||||
return &Client{
|
||||
Store: store,
|
||||
Auth: resolver,
|
||||
regular: trans.New(60 * time.Second),
|
||||
stream: trans.New(0),
|
||||
fallback: &http.Client{Timeout: 60 * time.Second},
|
||||
fallbackS: &http.Client{Timeout: 0},
|
||||
powSolver: NewPowSolver(config.WASMPath()),
|
||||
maxRetries: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
|
||||
payload := map[string]any{
|
||||
"password": strings.TrimSpace(acc.Password),
|
||||
"device_id": "deepseek_to_api",
|
||||
"os": "android",
|
||||
}
|
||||
if email := strings.TrimSpace(acc.Email); email != "" {
|
||||
payload["email"] = email
|
||||
} else if mobile := strings.TrimSpace(acc.Mobile); mobile != "" {
|
||||
payload["mobile"] = mobile
|
||||
payload["area_code"] = nil
|
||||
} else {
|
||||
return "", errors.New("missing email/mobile")
|
||||
}
|
||||
resp, err := c.postJSON(ctx, c.regular, DeepSeekLoginURL, BaseHeaders, payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
code := intFrom(resp["code"])
|
||||
if code != 0 {
|
||||
return "", fmt.Errorf("login failed: %v", resp["msg"])
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
if intFrom(data["biz_code"]) != 0 {
|
||||
return "", fmt.Errorf("login failed: %v", data["biz_msg"])
|
||||
}
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
user, _ := bizData["user"].(map[string]any)
|
||||
token, _ := user["token"].(string)
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return "", errors.New("missing login token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreateSessionURL, headers, map[string]any{"agent": "chat"})
|
||||
if err != nil {
|
||||
config.Logger.Warn("[create_session] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code := intFrom(resp["code"])
|
||||
if status == http.StatusOK && code == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
sessionID, _ := bizData["id"].(string)
|
||||
if sessionID != "" {
|
||||
return sessionID, nil
|
||||
}
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
|
||||
if a.UseConfigToken {
|
||||
if isTokenInvalid(status, code, msg) && !refreshed {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
refreshed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
return "", errors.New("create session failed")
|
||||
}
|
||||
|
||||
func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
attempts := 0
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
|
||||
if err != nil {
|
||||
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code := intFrom(resp["code"])
|
||||
if status == http.StatusOK && code == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
challenge, _ := bizData["challenge"].(map[string]any)
|
||||
answer, err := c.powSolver.Compute(ctx, challenge)
|
||||
if err != nil {
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
return BuildPowHeader(challenge, answer)
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
|
||||
if a.UseConfigToken {
|
||||
if isTokenInvalid(status, code, msg) {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
return "", errors.New("get pow failed")
|
||||
}
|
||||
|
||||
func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
headers["x-ds-pow-response"] = powResp
|
||||
attempts := 0
|
||||
for attempts < maxAttempts {
|
||||
resp, err := c.streamPost(ctx, DeepSeekCompletionURL, headers, payload)
|
||||
if err != nil {
|
||||
attempts++
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return resp, nil
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
attempts++
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, errors.New("completion failed")
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (map[string]any, error) {
|
||||
body, status, err := c.postJSONWithStatus(ctx, doer, url, headers, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == 0 {
|
||||
return nil, errors.New("request failed")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string, payload any) (map[string]any, int, error) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := doer.Do(req)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[deepseek] fingerprint request failed, fallback to std transport", "url", url, "error", err)
|
||||
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if reqErr != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
resp, err = c.fallback.Do(req2)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payloadBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
if len(payloadBytes) > 0 {
|
||||
if err := json.Unmarshal(payloadBytes, &out); err != nil {
|
||||
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
|
||||
}
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) streamPost(ctx context.Context, url string, headers map[string]string, payload any) (*http.Response, error) {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := c.stream.Do(req)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[deepseek] fingerprint stream request failed, fallback to std transport", "url", url, "error", err)
|
||||
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||
if reqErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
return c.fallbackS.Do(req2)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders(token string) map[string]string {
|
||||
headers := make(map[string]string, len(BaseHeaders)+1)
|
||||
for k, v := range BaseHeaders {
|
||||
headers[k] = v
|
||||
}
|
||||
headers["authorization"] = "Bearer " + token
|
||||
return headers
|
||||
}
|
||||
|
||||
func isTokenInvalid(status int, code int, msg string) bool {
|
||||
msg = strings.ToLower(msg)
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
if code == 40001 || code == 40002 || code == 40003 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized")
|
||||
}
|
||||
|
||||
func intFrom(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func readResponseBody(resp *http.Response) ([]byte, error) {
|
||||
encoding := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
|
||||
var reader io.Reader = resp.Body
|
||||
switch encoding {
|
||||
case "gzip":
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
reader = gz
|
||||
case "br":
|
||||
reader = brotli.NewReader(resp.Body)
|
||||
}
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func preview(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 160 {
|
||||
return s[:160]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
if !onLine(scanner.Bytes()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
26
internal/deepseek/constants.go
Normal file
26
internal/deepseek/constants.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package deepseek
|
||||
|
||||
const (
|
||||
DeepSeekHost = "chat.deepseek.com"
|
||||
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
||||
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
||||
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
||||
)
|
||||
|
||||
var BaseHeaders = map[string]string{
|
||||
"Host": "chat.deepseek.com",
|
||||
"User-Agent": "DeepSeek/1.6.11 Android/35",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"x-client-platform": "android",
|
||||
"x-client-version": "1.6.11",
|
||||
"x-client-locale": "zh_CN",
|
||||
"accept-charset": "UTF-8",
|
||||
}
|
||||
|
||||
const (
|
||||
KeepAliveTimeout = 5
|
||||
StreamIdleTimeout = 30
|
||||
MaxKeepaliveCount = 10
|
||||
)
|
||||
189
internal/deepseek/pow.go
Normal file
189
internal/deepseek/pow.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"ds2api/internal/config"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
type PowSolver struct {
|
||||
wasmPath string
|
||||
once sync.Once
|
||||
err error
|
||||
|
||||
runtime wazero.Runtime
|
||||
compiled wazero.CompiledModule
|
||||
}
|
||||
|
||||
func NewPowSolver(wasmPath string) *PowSolver {
|
||||
return &PowSolver{wasmPath: wasmPath}
|
||||
}
|
||||
|
||||
func (p *PowSolver) init(ctx context.Context) error {
|
||||
p.once.Do(func() {
|
||||
wasmBytes, err := os.ReadFile(p.wasmPath)
|
||||
if err != nil {
|
||||
p.err = err
|
||||
return
|
||||
}
|
||||
p.runtime = wazero.NewRuntime(ctx)
|
||||
p.compiled, p.err = p.runtime.CompileModule(ctx, wasmBytes)
|
||||
})
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *PowSolver) Compute(ctx context.Context, challenge map[string]any) (int64, error) {
|
||||
if err := p.init(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
algo, _ := challenge["algorithm"].(string)
|
||||
if algo != "DeepSeekHashV1" {
|
||||
return 0, errors.New("unsupported algorithm")
|
||||
}
|
||||
challengeStr, _ := challenge["challenge"].(string)
|
||||
salt, _ := challenge["salt"].(string)
|
||||
signature, _ := challenge["signature"].(string)
|
||||
targetPath, _ := challenge["target_path"].(string)
|
||||
_ = signature
|
||||
_ = targetPath
|
||||
|
||||
difficulty := toFloat64(challenge["difficulty"], 144000)
|
||||
expireAt := toInt64(challenge["expire_at"], 1680000000)
|
||||
prefix := salt + "_" + itoa(expireAt) + "_"
|
||||
|
||||
mod, err := p.runtime.InstantiateModule(ctx, p.compiled, wazero.NewModuleConfig())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer mod.Close(ctx)
|
||||
|
||||
mem := mod.Memory()
|
||||
if mem == nil {
|
||||
return 0, errors.New("wasm memory missing")
|
||||
}
|
||||
stackFn := mod.ExportedFunction("__wbindgen_add_to_stack_pointer")
|
||||
allocFn := mod.ExportedFunction("__wbindgen_export_0")
|
||||
solveFn := mod.ExportedFunction("wasm_solve")
|
||||
if stackFn == nil || allocFn == nil || solveFn == nil {
|
||||
return 0, errors.New("required wasm exports missing")
|
||||
}
|
||||
|
||||
retPtrs, err := stackFn.Call(ctx, uint64(uint32(^uint32(15)))) // -16 i32
|
||||
if err != nil || len(retPtrs) == 0 {
|
||||
return 0, errors.New("stack alloc failed")
|
||||
}
|
||||
retptr := uint32(retPtrs[0])
|
||||
defer stackFn.Call(ctx, 16)
|
||||
|
||||
chPtr, chLen, err := writeUTF8(ctx, allocFn, mem, challengeStr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
prefixPtr, prefixLen, err := writeUTF8(ctx, allocFn, mem, prefix)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := solveFn.Call(ctx,
|
||||
uint64(retptr),
|
||||
uint64(chPtr), uint64(chLen),
|
||||
uint64(prefixPtr), uint64(prefixLen),
|
||||
math.Float64bits(difficulty),
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
statusBytes, ok := mem.Read(retptr, 4)
|
||||
if !ok {
|
||||
return 0, errors.New("read status failed")
|
||||
}
|
||||
status := int32(binary.LittleEndian.Uint32(statusBytes))
|
||||
valueBytes, ok := mem.Read(retptr+8, 8)
|
||||
if !ok {
|
||||
return 0, errors.New("read value failed")
|
||||
}
|
||||
value := math.Float64frombits(binary.LittleEndian.Uint64(valueBytes))
|
||||
if status == 0 {
|
||||
return 0, errors.New("pow solve failed")
|
||||
}
|
||||
return int64(value), nil
|
||||
}
|
||||
|
||||
func writeUTF8(ctx context.Context, allocFn api.Function, mem api.Memory, text string) (uint32, uint32, error) {
|
||||
data := []byte(text)
|
||||
res, err := allocFn.Call(ctx, uint64(len(data)), 1)
|
||||
if err != nil || len(res) == 0 {
|
||||
return 0, 0, errors.New("alloc failed")
|
||||
}
|
||||
ptr := uint32(res[0])
|
||||
if !mem.Write(ptr, data) {
|
||||
return 0, 0, errors.New("mem write failed")
|
||||
}
|
||||
return ptr, uint32(len(data)), nil
|
||||
}
|
||||
|
||||
func BuildPowHeader(challenge map[string]any, answer int64) (string, error) {
|
||||
payload := map[string]any{
|
||||
"algorithm": challenge["algorithm"],
|
||||
"challenge": challenge["challenge"],
|
||||
"salt": challenge["salt"],
|
||||
"answer": answer,
|
||||
"signature": challenge["signature"],
|
||||
"target_path": challenge["target_path"],
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func toFloat64(v any, d float64) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func toInt64(v any, d int64) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func PreloadWASM(wasmPath string) {
|
||||
solver := NewPowSolver(wasmPath)
|
||||
if err := solver.init(context.Background()); err != nil {
|
||||
config.Logger.Warn("[WASM] preload failed", "error", err)
|
||||
return
|
||||
}
|
||||
config.Logger.Info("[WASM] module preloaded", "path", wasmPath)
|
||||
}
|
||||
80
internal/deepseek/transport/transport.go
Normal file
80
internal/deepseek/transport/transport.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
type Doer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(timeout time.Duration) *Client {
|
||||
base := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
ForceAttemptHTTP2: false,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
DialTLSContext: safariTLSDialer(),
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
return &Client{http: &http.Client{Timeout: timeout, Transport: base}}
|
||||
}
|
||||
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
return c.http.Do(req)
|
||||
}
|
||||
|
||||
func safariTLSDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
plainConn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
uCfg := &utls.Config{ServerName: host}
|
||||
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
|
||||
if err := forceHTTP11ALPN(uConn); err != nil {
|
||||
_ = plainConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
err = uConn.HandshakeContext(ctx)
|
||||
if err != nil {
|
||||
_ = plainConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if negotiated := uConn.ConnectionState().NegotiatedProtocol; negotiated != "" && negotiated != "http/1.1" {
|
||||
_ = uConn.Close()
|
||||
return nil, fmt.Errorf("unexpected ALPN protocol negotiated: %s", negotiated)
|
||||
}
|
||||
return uConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func forceHTTP11ALPN(uConn *utls.UConn) error {
|
||||
if err := uConn.BuildHandshakeState(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ext := range uConn.Extensions {
|
||||
alpnExt, ok := ext.(*utls.ALPNExtension)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
alpnExt.AlpnProtocols = []string{"http/1.1"}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
105
internal/server/router.go
Normal file
105
internal/server/router.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/adapter/claude"
|
||||
"ds2api/internal/adapter/openai"
|
||||
"ds2api/internal/admin"
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
"ds2api/internal/webui"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Store *config.Store
|
||||
Pool *account.Pool
|
||||
Resolver *auth.Resolver
|
||||
DS *deepseek.Client
|
||||
Router http.Handler
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
store := config.LoadStore()
|
||||
pool := account.NewPool(store)
|
||||
var dsClient *deepseek.Client
|
||||
resolver := auth.NewResolver(store, pool, func(ctx context.Context, acc config.Account) (string, error) {
|
||||
return dsClient.Login(ctx, acc)
|
||||
})
|
||||
dsClient = deepseek.NewClient(store, resolver)
|
||||
deepseek.PreloadWASM(config.WASMPath())
|
||||
|
||||
openaiHandler := &openai.Handler{Store: store, Auth: resolver, DS: dsClient}
|
||||
claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient}
|
||||
adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient}
|
||||
webuiHandler := webui.NewHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(cors)
|
||||
r.Use(timeout(0))
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
r.Get("/readyz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ready"}`))
|
||||
})
|
||||
openai.RegisterRoutes(r, openaiHandler)
|
||||
claude.RegisterRoutes(r, claudeHandler)
|
||||
r.Route("/admin", func(ar chi.Router) {
|
||||
admin.RegisterRoutes(ar, adminHandler)
|
||||
})
|
||||
webui.RegisterRoutes(r, webuiHandler)
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
if strings.HasPrefix(req.URL.Path, "/admin/") && webuiHandler.HandleAdminFallback(w, req) {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
})
|
||||
|
||||
return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, Router: r}
|
||||
}
|
||||
|
||||
func timeout(d time.Duration) func(http.Handler) http.Handler {
|
||||
if d <= 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
return middleware.Timeout(d)
|
||||
}
|
||||
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func WriteUnhandledError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "api_error", "message": "Internal Server Error", "detail": err.Error()}})
|
||||
}
|
||||
259
internal/sse/parser.go
Normal file
259
internal/sse/parser.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ContentPart struct {
|
||||
Text string
|
||||
Type string
|
||||
}
|
||||
|
||||
var skipPatterns = []string{
|
||||
"quasi_status", "elapsed_secs", "token_usage", "pending_fragment", "conversation_mode",
|
||||
"fragments/-1/status", "fragments/-2/status", "fragments/-3/status",
|
||||
}
|
||||
|
||||
func ParseDeepSeekSSELine(raw []byte) (map[string]any, bool, bool) {
|
||||
line := strings.TrimSpace(string(raw))
|
||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||
return nil, false, false
|
||||
}
|
||||
dataStr := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if dataStr == "[DONE]" {
|
||||
return nil, true, true
|
||||
}
|
||||
chunk := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
return chunk, false, true
|
||||
}
|
||||
|
||||
func shouldSkipPath(path string) bool {
|
||||
if path == "response/search_status" {
|
||||
return true
|
||||
}
|
||||
for _, p := range skipPatterns {
|
||||
if strings.Contains(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, bool, string) {
|
||||
v, ok := chunk["v"]
|
||||
if !ok {
|
||||
return nil, false, currentFragmentType
|
||||
}
|
||||
path, _ := chunk["p"].(string)
|
||||
if shouldSkipPath(path) {
|
||||
return nil, false, currentFragmentType
|
||||
}
|
||||
if path == "response/status" {
|
||||
if s, ok := v.(string); ok && s == "FINISHED" {
|
||||
return nil, true, currentFragmentType
|
||||
}
|
||||
}
|
||||
newType := currentFragmentType
|
||||
parts := make([]ContentPart, 0, 8)
|
||||
|
||||
// Newer DeepSeek responses may emit fragment APPEND directly on
|
||||
// path "response/fragments" instead of wrapping it in path "response".
|
||||
if path == "response/fragments" {
|
||||
if op, _ := chunk["o"].(string); strings.EqualFold(op, "APPEND") {
|
||||
if frags, ok := v.([]any); ok {
|
||||
for _, frag := range frags {
|
||||
fm, ok := frag.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := fm["type"].(string)
|
||||
content, _ := fm["content"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
switch t {
|
||||
case "THINK", "THINKING":
|
||||
newType = "thinking"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
}
|
||||
case "RESPONSE":
|
||||
newType = "text"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
default:
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path == "response" {
|
||||
if arr, ok := v.([]any); ok {
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["p"] == "fragments" && m["o"] == "APPEND" {
|
||||
if frags, ok := m["v"].([]any); ok {
|
||||
for _, frag := range frags {
|
||||
fm, ok := frag.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := fm["type"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
if t == "THINK" || t == "THINKING" {
|
||||
newType = "thinking"
|
||||
} else if t == "RESPONSE" {
|
||||
newType = "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
partType := "text"
|
||||
switch {
|
||||
case path == "response/thinking_content":
|
||||
partType = "thinking"
|
||||
case path == "response/content":
|
||||
partType = "text"
|
||||
case strings.Contains(path, "response/fragments") && strings.Contains(path, "/content"):
|
||||
partType = newType
|
||||
case path == "":
|
||||
if thinkingEnabled {
|
||||
partType = newType
|
||||
}
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
if val == "FINISHED" && (path == "" || path == "status") {
|
||||
return nil, true, newType
|
||||
}
|
||||
if val != "" {
|
||||
parts = append(parts, ContentPart{Text: val, Type: partType})
|
||||
}
|
||||
case []any:
|
||||
pp, finished := extractContentRecursive(val, partType)
|
||||
if finished {
|
||||
return nil, true, newType
|
||||
}
|
||||
parts = append(parts, pp...)
|
||||
case map[string]any:
|
||||
resp := val
|
||||
if wrapped, ok := val["response"].(map[string]any); ok {
|
||||
resp = wrapped
|
||||
}
|
||||
if frags, ok := resp["fragments"].([]any); ok {
|
||||
for _, item := range frags {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t, _ := m["type"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
t = strings.ToUpper(t)
|
||||
if t == "THINK" || t == "THINKING" {
|
||||
newType = "thinking"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
}
|
||||
} else if t == "RESPONSE" {
|
||||
newType = "text"
|
||||
if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
}
|
||||
} else if content != "" {
|
||||
parts = append(parts, ContentPart{Text: content, Type: partType})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts, false, newType
|
||||
}
|
||||
|
||||
func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bool) {
|
||||
parts := make([]ContentPart, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
itemPath, _ := m["p"].(string)
|
||||
itemV, hasV := m["v"]
|
||||
if !hasV {
|
||||
continue
|
||||
}
|
||||
if itemPath == "status" {
|
||||
if s, ok := itemV.(string); ok && s == "FINISHED" {
|
||||
return nil, true
|
||||
}
|
||||
}
|
||||
if shouldSkipPath(itemPath) {
|
||||
continue
|
||||
}
|
||||
if content, ok := m["content"].(string); ok && content != "" {
|
||||
typeName, _ := m["type"].(string)
|
||||
typeName = strings.ToUpper(typeName)
|
||||
switch typeName {
|
||||
case "THINK", "THINKING":
|
||||
parts = append(parts, ContentPart{Text: content, Type: "thinking"})
|
||||
case "RESPONSE":
|
||||
parts = append(parts, ContentPart{Text: content, Type: "text"})
|
||||
default:
|
||||
parts = append(parts, ContentPart{Text: content, Type: defaultType})
|
||||
}
|
||||
continue
|
||||
}
|
||||
partType := defaultType
|
||||
if strings.Contains(itemPath, "thinking") {
|
||||
partType = "thinking"
|
||||
} else if strings.Contains(itemPath, "content") || itemPath == "response" || itemPath == "fragments" {
|
||||
partType = "text"
|
||||
}
|
||||
switch v := itemV.(type) {
|
||||
case string:
|
||||
if v != "" && v != "FINISHED" {
|
||||
parts = append(parts, ContentPart{Text: v, Type: partType})
|
||||
}
|
||||
case []any:
|
||||
for _, inner := range v {
|
||||
switch x := inner.(type) {
|
||||
case map[string]any:
|
||||
ct, _ := x["content"].(string)
|
||||
if ct == "" {
|
||||
continue
|
||||
}
|
||||
typeName, _ := x["type"].(string)
|
||||
typeName = strings.ToUpper(typeName)
|
||||
if typeName == "THINK" || typeName == "THINKING" {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "thinking"})
|
||||
} else if typeName == "RESPONSE" {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "text"})
|
||||
} else {
|
||||
parts = append(parts, ContentPart{Text: ct, Type: partType})
|
||||
}
|
||||
case string:
|
||||
if x != "" {
|
||||
parts = append(parts, ContentPart{Text: x, Type: partType})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts, false
|
||||
}
|
||||
|
||||
func IsCitation(text string) bool {
|
||||
return bytes.HasPrefix([]byte(strings.TrimSpace(text)), []byte("[citation:"))
|
||||
}
|
||||
89
internal/sse/parser_test.go
Normal file
89
internal/sse/parser_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDeepSeekSSELine(t *testing.T) {
|
||||
chunk, done, ok := ParseDeepSeekSSELine([]byte(`data: {"v":"你好"}`))
|
||||
if !ok || done {
|
||||
t.Fatalf("expected parsed chunk")
|
||||
}
|
||||
if chunk["v"] != "你好" {
|
||||
t.Fatalf("unexpected chunk: %#v", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineDone(t *testing.T) {
|
||||
_, done, ok := ParseDeepSeekSSELine([]byte(`data: [DONE]`))
|
||||
if !ok || !done {
|
||||
t.Fatalf("expected done signal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentSimple(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{"v": "hello"}, false, "text")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Text != "hello" || parts[0].Type != "text" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentThinking(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{"p": "response/thinking_content", "v": "think"}, true, "thinking")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCitation(t *testing.T) {
|
||||
if !IsCitation("[citation:1] abc") {
|
||||
t.Fatal("expected citation true")
|
||||
}
|
||||
if IsCitation("normal text") {
|
||||
t.Fatal("expected citation false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendSwitchToResponse(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "你好",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "thinking")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected next type text, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "text" || parts[0].Text != "你好" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentAfterAppendUsesUpdatedType(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments/-1/content",
|
||||
"v": "!",
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if finished {
|
||||
t.Fatal("expected unfinished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected next type text, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "text" || parts[0].Text != "!" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
127
internal/util/messages.go
Normal file
127
internal/util/messages.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
|
||||
|
||||
const ClaudeDefaultModel = "claude-sonnet-4-20250514"
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
|
||||
func MessagesPrepare(messages []map[string]any) string {
|
||||
type block struct {
|
||||
Role string
|
||||
Text string
|
||||
}
|
||||
processed := make([]block, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
role, _ := m["role"].(string)
|
||||
text := normalizeContent(m["content"])
|
||||
processed = append(processed, block{Role: role, Text: text})
|
||||
}
|
||||
if len(processed) == 0 {
|
||||
return ""
|
||||
}
|
||||
merged := make([]block, 0, len(processed))
|
||||
for _, msg := range processed {
|
||||
if len(merged) > 0 && merged[len(merged)-1].Role == msg.Role {
|
||||
merged[len(merged)-1].Text += "\n\n" + msg.Text
|
||||
continue
|
||||
}
|
||||
merged = append(merged, msg)
|
||||
}
|
||||
parts := make([]string, 0, len(merged))
|
||||
for i, m := range merged {
|
||||
switch m.Role {
|
||||
case "assistant":
|
||||
parts = append(parts, "<|Assistant|>"+m.Text+"<|end▁of▁sentence|>")
|
||||
case "user", "system":
|
||||
if i > 0 {
|
||||
parts = append(parts, "<|User|>"+m.Text)
|
||||
} else {
|
||||
parts = append(parts, m.Text)
|
||||
}
|
||||
default:
|
||||
parts = append(parts, m.Text)
|
||||
}
|
||||
}
|
||||
out := strings.Join(parts, "")
|
||||
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
|
||||
}
|
||||
|
||||
func normalizeContent(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []any:
|
||||
parts := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m["type"] == "text" {
|
||||
if txt, ok := m["text"].(string); ok {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertClaudeToDeepSeek(claudeReq map[string]any, store *config.Store) map[string]any {
|
||||
messages, _ := claudeReq["messages"].([]any)
|
||||
model, _ := claudeReq["model"].(string)
|
||||
if model == "" {
|
||||
model = ClaudeDefaultModel
|
||||
}
|
||||
mapping := store.ClaudeMapping()
|
||||
dsModel := mapping["fast"]
|
||||
if dsModel == "" {
|
||||
dsModel = "deepseek-chat"
|
||||
}
|
||||
modelLower := strings.ToLower(model)
|
||||
if strings.Contains(modelLower, "opus") || strings.Contains(modelLower, "reasoner") || strings.Contains(modelLower, "slow") {
|
||||
if slow := mapping["slow"]; slow != "" {
|
||||
dsModel = slow
|
||||
}
|
||||
}
|
||||
convertedMessages := make([]any, 0, len(messages)+1)
|
||||
if system, ok := claudeReq["system"].(string); ok && system != "" {
|
||||
convertedMessages = append(convertedMessages, map[string]any{"role": "system", "content": system})
|
||||
}
|
||||
convertedMessages = append(convertedMessages, messages...)
|
||||
|
||||
out := map[string]any{"model": dsModel, "messages": convertedMessages}
|
||||
for _, k := range []string{"temperature", "top_p", "stream"} {
|
||||
if v, ok := claudeReq[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if stopSeq, ok := claudeReq["stop_sequences"]; ok {
|
||||
out["stop"] = stopSeq
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func EstimateTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
n := len([]rune(text)) / 4
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
69
internal/util/messages_test.go
Normal file
69
internal/util/messages_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func TestMessagesPrepareBasic(t *testing.T) {
|
||||
messages := []map[string]any{{"role": "user", "content": "Hello"}}
|
||||
got := MessagesPrepare(messages)
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty prompt")
|
||||
}
|
||||
if got != "Hello" {
|
||||
t.Fatalf("unexpected prompt: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesPrepareRoles(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"role": "system", "content": "You are helper"},
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello"},
|
||||
{"role": "user", "content": "How are you"},
|
||||
}
|
||||
got := MessagesPrepare(messages)
|
||||
if !contains(got, "<|Assistant|>") {
|
||||
t.Fatalf("expected assistant marker in %q", got)
|
||||
}
|
||||
if !contains(got, "<|User|>") {
|
||||
t.Fatalf("expected user marker in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeToDeepSeek(t *testing.T) {
|
||||
store := config.LoadStore()
|
||||
req := map[string]any{
|
||||
"model": "claude-sonnet-4-20250514-slow",
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Hi"}},
|
||||
"system": "You are helpful",
|
||||
"stream": true,
|
||||
}
|
||||
out := ConvertClaudeToDeepSeek(req, store)
|
||||
if out["model"] == "" {
|
||||
t.Fatal("expected mapped model")
|
||||
}
|
||||
msgs, ok := out["messages"].([]any)
|
||||
if !ok || len(msgs) == 0 {
|
||||
t.Fatal("expected messages")
|
||||
}
|
||||
first, _ := msgs[0].(map[string]any)
|
||||
if first["role"] != "system" {
|
||||
t.Fatalf("expected first message system, got %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || (len(s) > 0 && (indexOf(s, sub) >= 0)))
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
300
internal/util/toolcalls.go
Normal file
300
internal/util/toolcalls.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
|
||||
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
|
||||
type ParsedToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
}
|
||||
|
||||
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := buildToolCallCandidates(text)
|
||||
var parsed []ParsedToolCall
|
||||
for _, candidate := range candidates {
|
||||
if tc := parseToolCallsPayload(candidate); len(tc) > 0 {
|
||||
parsed = tc
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{}
|
||||
for _, name := range availableToolNames {
|
||||
allowed[name] = struct{}{}
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if len(allowed) > 0 {
|
||||
if _, ok := allowed[tc.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
// If the model clearly emitted tool_calls JSON but all names are outside the
|
||||
// declared set, keep the parsed calls as a fallback so upper layers can still
|
||||
// intercept structured tool output instead of leaking raw JSON to users.
|
||||
if len(out) == 0 && len(parsed) > 0 {
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildToolCallCandidates(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
candidates := []string{trimmed}
|
||||
|
||||
// fenced code block candidates: ```json ... ```
|
||||
for _, match := range fencedJSONPattern.FindAllStringSubmatch(trimmed, -1) {
|
||||
if len(match) >= 2 {
|
||||
candidates = append(candidates, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// best-effort extraction around "tool_calls" key in mixed text payloads.
|
||||
candidates = append(candidates, extractToolCallObjects(trimmed)...)
|
||||
|
||||
// best-effort object slice: from first '{' to last '}'
|
||||
first := strings.Index(trimmed, "{")
|
||||
last := strings.LastIndex(trimmed, "}")
|
||||
if first >= 0 && last > first {
|
||||
candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1]))
|
||||
}
|
||||
|
||||
// legacy regex extraction fallback
|
||||
if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 {
|
||||
candidates = append(candidates, "{"+`"tool_calls":[`+m[1]+"]}")
|
||||
}
|
||||
|
||||
uniq := make([]string, 0, len(candidates))
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range candidates {
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
uniq = append(uniq, c)
|
||||
}
|
||||
return uniq
|
||||
}
|
||||
|
||||
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch v := decoded.(type) {
|
||||
case map[string]any:
|
||||
if tc, ok := v["tool_calls"]; ok {
|
||||
return parseToolCallList(tc)
|
||||
}
|
||||
if parsed, ok := parseToolCallItem(v); ok {
|
||||
return []ParsedToolCall{parsed}
|
||||
}
|
||||
case []any:
|
||||
return parseToolCallList(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseToolCallList(v any) []ParsedToolCall {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tc, ok := parseToolCallItem(m); ok {
|
||||
out = append(out, tc)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
|
||||
name, _ := m["name"].(string)
|
||||
inputRaw, hasInput := m["input"]
|
||||
if fn, ok := m["function"].(map[string]any); ok {
|
||||
if name == "" {
|
||||
name, _ = fn["name"].(string)
|
||||
}
|
||||
if !hasInput {
|
||||
if v, ok := fn["arguments"]; ok {
|
||||
inputRaw = v
|
||||
hasInput = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasInput {
|
||||
for _, key := range []string{"arguments", "args", "parameters", "params"} {
|
||||
if v, ok := m[key]; ok {
|
||||
inputRaw = v
|
||||
hasInput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
return ParsedToolCall{
|
||||
Name: strings.TrimSpace(name),
|
||||
Input: parseToolCallInput(inputRaw),
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseToolCallInput(v any) map[string]any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return x
|
||||
case string:
|
||||
raw := strings.TrimSpace(x)
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
return map[string]any{"_raw": raw}
|
||||
default:
|
||||
b, err := json.Marshal(x)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
|
||||
func extractToolCallObjects(text string) []string {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
out := []string{}
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(lower[offset:], "tool_calls")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
idx += offset
|
||||
start := strings.LastIndex(text[:idx], "{")
|
||||
for start >= 0 {
|
||||
candidate, end, ok := extractJSONObject(text, start)
|
||||
if ok {
|
||||
// Move forward to avoid repeatedly matching the same object.
|
||||
offset = end
|
||||
out = append(out, strings.TrimSpace(candidate))
|
||||
break
|
||||
}
|
||||
start = strings.LastIndex(text[:start], "{")
|
||||
}
|
||||
if start < 0 {
|
||||
offset = idx + len("tool_calls")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractJSONObject(text string, start int) (string, int, bool) {
|
||||
if start < 0 || start >= len(text) || text[start] != '{' {
|
||||
return "", 0, false
|
||||
}
|
||||
depth := 0
|
||||
quote := byte(0)
|
||||
escaped := false
|
||||
for i := start; i < len(text); i++ {
|
||||
ch := text[i]
|
||||
if quote != 0 {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if ch == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == '"' || ch == '\'' {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if ch == '{' {
|
||||
depth++
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return text[start : i+1], i + 1, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func FormatOpenAIToolCalls(calls []ParsedToolCall) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
args, _ := json.Marshal(c.Input)
|
||||
out = append(out, map[string]any{
|
||||
"id": "call_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": c.Name,
|
||||
"arguments": string(args),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
64
internal/util/toolcalls_test.go
Normal file
64
internal/util/toolcalls_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseToolCalls(t *testing.T) {
|
||||
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "search" {
|
||||
t.Fatalf("unexpected tool name: %s", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["q"] != "golang" {
|
||||
t.Fatalf("unexpected args: %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsFromFencedJSON(t *testing.T) {
|
||||
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Input["q"] != "news" {
|
||||
t.Fatalf("unexpected args: %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) {
|
||||
text := `{"tool_calls":[{"function":{"name":"get_weather","arguments":"{\"city\":\"beijing\"}"}}]}`
|
||||
calls := ParseToolCalls(text, []string{"get_weather"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "get_weather" {
|
||||
t.Fatalf("unexpected tool name: %s", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["city"] != "beijing" {
|
||||
t.Fatalf("unexpected args: %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsKeepsUnknownAsFallback(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected fallback 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "unknown" {
|
||||
t.Fatalf("unexpected name: %s", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOpenAIToolCalls(t *testing.T) {
|
||||
formatted := FormatOpenAIToolCalls([]ParsedToolCall{{Name: "search", Input: map[string]any{"q": "x"}}})
|
||||
if len(formatted) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(formatted))
|
||||
}
|
||||
fn, _ := formatted[0]["function"].(map[string]any)
|
||||
if fn["name"] != "search" {
|
||||
t.Fatalf("unexpected function name: %#v", fn)
|
||||
}
|
||||
}
|
||||
81
internal/webui/handler.go
Normal file
81
internal/webui/handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
const welcomeHTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>DS2API</title>
|
||||
<style>body{font-family:Inter,system-ui,sans-serif;background:#030712;color:#f9fafb;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}a{color:#f59e0b;text-decoration:none}main{max-width:700px;padding:24px;text-align:center}h1{font-size:48px;margin:0 0 12px}.links{display:flex;gap:16px;justify-content:center;margin-top:20px;flex-wrap:wrap}</style>
|
||||
</head><body><main><h1>DS2API</h1><p>DeepSeek to OpenAI & Claude Compatible API</p><div class="links"><a href="/admin">管理面板</a><a href="/v1/models">API 状态</a><a href="https://github.com/CJackHwang/ds2api" target="_blank">GitHub</a></div></main></body></html>`
|
||||
|
||||
type Handler struct {
|
||||
StaticDir string
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{StaticDir: config.StaticAdminDir()}
|
||||
}
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/", h.index)
|
||||
r.Get("/admin", h.admin)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAdminFallback(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/admin/") {
|
||||
return false
|
||||
}
|
||||
h.admin(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) index(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(welcomeHTML))
|
||||
}
|
||||
|
||||
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
||||
if fi, err := os.Stat(h.StaticDir); err != nil || !fi.IsDir() {
|
||||
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if path != "" && strings.Contains(path, ".") {
|
||||
full := filepath.Join(h.StaticDir, filepath.Clean(path))
|
||||
if !strings.HasPrefix(full, h.StaticDir) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
index := filepath.Join(h.StaticDir, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
http.Error(w, "index.html not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
http.ServeFile(w, r, index)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# DS2API 依赖
|
||||
# 安装命令: pip install -r requirements.txt
|
||||
# Python 版本要求: >=3.9
|
||||
|
||||
# ===== Web 框架 =====
|
||||
fastapi>=0.110.0,<1.0.0
|
||||
uvicorn[standard]>=0.24.0,<1.0.0
|
||||
|
||||
# ===== HTTP 客户端 =====
|
||||
# curl_cffi: 支持 TLS 指纹模拟,绕过 Cloudflare 等防护
|
||||
curl_cffi>=0.7.0
|
||||
# httpx: 异步 HTTP 客户端,用于 Vercel API 调用
|
||||
httpx>=0.25.0
|
||||
|
||||
# ===== 模板引擎 =====
|
||||
jinja2>=3.1.0,<4.0.0
|
||||
|
||||
# ===== Tokenizer =====
|
||||
# 用于 token 计数(可选,不安装则使用估算方式)
|
||||
transformers>=4.39.0,<5.0.0
|
||||
|
||||
# ===== WASM 运行时 =====
|
||||
# 用于 DeepSeek PoW (Proof of Work) 计算
|
||||
wasmtime>=14.0.0
|
||||
@@ -1 +0,0 @@
|
||||
# DS2API Routes
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin 路由模块 - 合并所有子模块路由"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router, verify_admin, ADMIN_KEY
|
||||
from .config import router as config_router
|
||||
from .accounts import router as accounts_router
|
||||
from .vercel import router as vercel_router
|
||||
|
||||
# 创建主路由
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# 包含所有子路由
|
||||
router.include_router(auth_router)
|
||||
router.include_router(config_router)
|
||||
router.include_router(accounts_router)
|
||||
router.include_router(vercel_router)
|
||||
|
||||
# 导出常用依赖
|
||||
__all__ = ["router", "verify_admin", "ADMIN_KEY"]
|
||||
@@ -1,342 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin 账号管理模块 - 账号测试与导入"""
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from core.config import CONFIG, save_config, logger, WASM_PATH
|
||||
from core.auth import init_account_queue, get_account_identifier
|
||||
from core.deepseek import (
|
||||
login_deepseek_via_account,
|
||||
DEEPSEEK_CREATE_SESSION_URL,
|
||||
DEEPSEEK_COMPLETION_URL,
|
||||
BASE_HEADERS,
|
||||
)
|
||||
from core.pow import compute_pow_answer
|
||||
from core.models import get_model_config
|
||||
from core.sse_parser import parse_sse_chunk_for_content
|
||||
|
||||
from .auth import verify_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 账号 API 测试
|
||||
# ----------------------------------------------------------------------
|
||||
async def test_account_api(account: dict, model: str = "deepseek-chat", message: str = "") -> dict:
|
||||
"""测试单个账号的 API 调用能力
|
||||
|
||||
如果提供 message,会发送实际请求并返回 AI 回复;
|
||||
否则只快速测试创建会话。
|
||||
"""
|
||||
from curl_cffi import requests as cffi_requests
|
||||
import time
|
||||
|
||||
acc_id = get_account_identifier(account)
|
||||
result = {
|
||||
"account": acc_id,
|
||||
"success": False,
|
||||
"response_time": 0,
|
||||
"message": "",
|
||||
"model": model,
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
def _is_token_invalid(status_code: int, data: dict) -> bool:
|
||||
msg = (data.get("msg") or data.get("message") or "").lower()
|
||||
code = data.get("code")
|
||||
return status_code in {401, 403} or code in {40001, 40002, 40003} or "token" in msg or "unauthorized" in msg
|
||||
|
||||
def _create_session(token: str) -> dict:
|
||||
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
|
||||
try:
|
||||
session_resp = cffi_requests.post(
|
||||
DEEPSEEK_CREATE_SESSION_URL,
|
||||
headers=headers,
|
||||
json={"agent": "chat"},
|
||||
impersonate="safari15_3",
|
||||
timeout=15,
|
||||
)
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"请求异常: {e}", "status_code": 0, "data": {}}
|
||||
|
||||
try:
|
||||
session_data = session_resp.json()
|
||||
except Exception:
|
||||
session_data = {}
|
||||
finally:
|
||||
session_resp.close()
|
||||
|
||||
if session_resp.status_code == 200 and session_data.get("code") == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_data.get("data", {}).get("biz_data", {}).get("id"),
|
||||
"status_code": session_resp.status_code,
|
||||
"data": session_data,
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": session_data.get("msg") or f"HTTP {session_resp.status_code}",
|
||||
"status_code": session_resp.status_code,
|
||||
"data": session_data,
|
||||
}
|
||||
|
||||
try:
|
||||
token = account.get("token", "").strip()
|
||||
session_result = None
|
||||
if token:
|
||||
session_result = _create_session(token)
|
||||
|
||||
if not token or (session_result and not session_result["success"] and _is_token_invalid(session_result["status_code"], session_result["data"])):
|
||||
try:
|
||||
account["token"] = ""
|
||||
login_deepseek_via_account(account)
|
||||
token = account.get("token", "")
|
||||
session_result = _create_session(token)
|
||||
except Exception as e:
|
||||
result["message"] = f"登录失败: {str(e)}"
|
||||
return result
|
||||
|
||||
if not session_result or not session_result["success"]:
|
||||
result["message"] = f"创建会话失败: {session_result['message'] if session_result else 'Unknown error'}"
|
||||
return result
|
||||
|
||||
session_id = session_result["session_id"]
|
||||
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
|
||||
|
||||
if not message.strip():
|
||||
result["success"] = True
|
||||
result["message"] = "API 测试成功(仅会话创建)"
|
||||
result["response_time"] = round((time.time() - start_time) * 1000)
|
||||
return result
|
||||
|
||||
pow_url = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
pow_resp = cffi_requests.post(
|
||||
pow_url,
|
||||
headers=headers,
|
||||
json={"target_path": "/api/v0/chat/completion"},
|
||||
timeout=30,
|
||||
impersonate="safari15_3",
|
||||
)
|
||||
|
||||
pow_data = pow_resp.json()
|
||||
if pow_data.get("code") != 0:
|
||||
result["message"] = f"获取 PoW 失败: {pow_data.get('msg')}"
|
||||
return result
|
||||
|
||||
challenge = pow_data["data"]["biz_data"]["challenge"]
|
||||
try:
|
||||
answer = compute_pow_answer(
|
||||
challenge["algorithm"],
|
||||
challenge["challenge"],
|
||||
challenge["salt"],
|
||||
challenge.get("difficulty", 144000),
|
||||
challenge.get("expire_at", 1680000000),
|
||||
challenge["signature"],
|
||||
challenge["target_path"],
|
||||
WASM_PATH,
|
||||
)
|
||||
except Exception as e:
|
||||
result["message"] = f"PoW 计算失败: {str(e)}"
|
||||
return result
|
||||
|
||||
pow_dict = {
|
||||
"algorithm": challenge["algorithm"],
|
||||
"challenge": challenge["challenge"],
|
||||
"salt": challenge["salt"],
|
||||
"answer": answer,
|
||||
"signature": challenge["signature"],
|
||||
"target_path": challenge["target_path"],
|
||||
}
|
||||
pow_str = json.dumps(pow_dict, separators=(",", ":"), ensure_ascii=False)
|
||||
pow_header = base64.b64encode(pow_str.encode("utf-8")).decode("utf-8").rstrip()
|
||||
|
||||
thinking_enabled, search_enabled = get_model_config(model)
|
||||
if thinking_enabled is None:
|
||||
thinking_enabled = False
|
||||
search_enabled = False
|
||||
|
||||
payload = {
|
||||
"chat_session_id": session_id,
|
||||
"prompt": f"<|User|>{message}",
|
||||
"ref_file_ids": [],
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"search_enabled": search_enabled,
|
||||
}
|
||||
|
||||
completion_headers = {**headers, "x-ds-pow-response": pow_header}
|
||||
|
||||
completion_resp = cffi_requests.post(
|
||||
DEEPSEEK_COMPLETION_URL,
|
||||
headers=completion_headers,
|
||||
json=payload,
|
||||
impersonate="safari15_3",
|
||||
timeout=60,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if completion_resp.status_code != 200:
|
||||
result["message"] = f"请求失败: HTTP {completion_resp.status_code}"
|
||||
return result
|
||||
|
||||
thinking_parts = []
|
||||
content_parts = []
|
||||
current_fragment_type = "thinking" if thinking_enabled else "text"
|
||||
|
||||
for line in completion_resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
line_str = line.decode("utf-8")
|
||||
except:
|
||||
continue
|
||||
|
||||
if not line_str.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_str = line_str[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
# 使用共享的解析函数
|
||||
contents, is_finished, current_fragment_type = parse_sse_chunk_for_content(
|
||||
chunk, thinking_enabled, current_fragment_type
|
||||
)
|
||||
|
||||
if is_finished:
|
||||
break
|
||||
|
||||
for content, ctype in contents:
|
||||
if ctype == "thinking":
|
||||
thinking_parts.append(content)
|
||||
else:
|
||||
content_parts.append(content)
|
||||
except:
|
||||
continue
|
||||
|
||||
completion_resp.close()
|
||||
|
||||
result["success"] = True
|
||||
result["response_time"] = round((time.time() - start_time) * 1000)
|
||||
result["message"] = "".join(content_parts) or "(无回复内容)"
|
||||
if thinking_parts:
|
||||
result["thinking"] = "".join(thinking_parts)
|
||||
|
||||
except Exception as e:
|
||||
result["message"] = f"测试失败: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/accounts/test")
|
||||
async def test_single_account(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""测试单个账号的 API 调用"""
|
||||
data = await request.json()
|
||||
identifier = data.get("identifier", "")
|
||||
model = data.get("model", "deepseek-chat")
|
||||
message = data.get("message", "")
|
||||
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=400, detail="需要账号标识(email 或 mobile)")
|
||||
|
||||
account = None
|
||||
for acc in CONFIG.get("accounts", []):
|
||||
if acc.get("email") == identifier or acc.get("mobile") == identifier:
|
||||
account = acc
|
||||
break
|
||||
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
result = await test_account_api(account, model, message)
|
||||
save_config(CONFIG)
|
||||
|
||||
return JSONResponse(content=result)
|
||||
|
||||
|
||||
@router.post("/accounts/test-all")
|
||||
async def test_all_accounts(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""批量测试所有账号的 API 调用"""
|
||||
data = await request.json()
|
||||
model = data.get("model", "deepseek-chat")
|
||||
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
if not accounts:
|
||||
return JSONResponse(content={
|
||||
"total": 0, "success": 0, "failed": 0, "results": [],
|
||||
})
|
||||
|
||||
results = []
|
||||
success_count = 0
|
||||
|
||||
for acc in accounts:
|
||||
result = await test_account_api(acc, model)
|
||||
results.append(result)
|
||||
if result["success"]:
|
||||
success_count += 1
|
||||
await asyncio.sleep(1)
|
||||
|
||||
save_config(CONFIG)
|
||||
|
||||
return JSONResponse(content={
|
||||
"total": len(accounts),
|
||||
"success": success_count,
|
||||
"failed": len(accounts) - success_count,
|
||||
"results": results,
|
||||
})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 批量导入
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/import")
|
||||
async def batch_import(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""批量导入 keys 和 accounts"""
|
||||
try:
|
||||
data = await request.json()
|
||||
imported_keys = 0
|
||||
imported_accounts = 0
|
||||
|
||||
if "keys" in data:
|
||||
for key in data["keys"]:
|
||||
if key not in CONFIG.get("keys", []):
|
||||
if "keys" not in CONFIG:
|
||||
CONFIG["keys"] = []
|
||||
CONFIG["keys"].append(key)
|
||||
imported_keys += 1
|
||||
|
||||
if "accounts" in data:
|
||||
existing_ids = set()
|
||||
for acc in CONFIG.get("accounts", []):
|
||||
existing_ids.add(acc.get("email", ""))
|
||||
existing_ids.add(acc.get("mobile", ""))
|
||||
|
||||
for acc in data["accounts"]:
|
||||
acc_id = acc.get("email", "") or acc.get("mobile", "")
|
||||
if acc_id and acc_id not in existing_ids:
|
||||
if "accounts" not in CONFIG:
|
||||
CONFIG["accounts"] = []
|
||||
CONFIG["accounts"].append(acc)
|
||||
existing_ids.add(acc_id)
|
||||
imported_accounts += 1
|
||||
|
||||
init_account_queue()
|
||||
save_config(CONFIG)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"imported_keys": imported_keys,
|
||||
"imported_accounts": imported_accounts,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="无效的 JSON 格式")
|
||||
except Exception as e:
|
||||
logger.error(f"[batch_import] 错误: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1,155 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin 认证模块 - JWT 和登录相关"""
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from core.config import logger
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Admin Key 验证(默认值适用于开发/演示环境,生产环境请务必修改)
|
||||
ADMIN_KEY = os.getenv("DS2API_ADMIN_KEY", "your-admin-secret-key")
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET = os.getenv("DS2API_JWT_SECRET", ADMIN_KEY or "ds2api-default-secret")
|
||||
JWT_EXPIRE_HOURS = int(os.getenv("DS2API_JWT_EXPIRE_HOURS", "24"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# JWT 工具函数(轻量实现,无需额外依赖)
|
||||
# ----------------------------------------------------------------------
|
||||
def _b64_encode(data: bytes) -> str:
|
||||
"""Base64 URL 安全编码"""
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
def _b64_decode(data: str) -> bytes:
|
||||
"""Base64 URL 安全解码"""
|
||||
padding = 4 - len(data) % 4
|
||||
if padding != 4:
|
||||
data += "=" * padding
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
def create_jwt_token(expire_hours: int = None) -> str:
|
||||
"""创建 JWT Token"""
|
||||
import json
|
||||
|
||||
if expire_hours is None:
|
||||
expire_hours = JWT_EXPIRE_HOURS
|
||||
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
payload = {
|
||||
"iat": int(time.time()),
|
||||
"exp": int(time.time()) + (expire_hours * 3600),
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
header_b64 = _b64_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _b64_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
|
||||
message = f"{header_b64}.{payload_b64}"
|
||||
signature = hmac.new(JWT_SECRET.encode(), message.encode(), hashlib.sha256).digest()
|
||||
signature_b64 = _b64_encode(signature)
|
||||
|
||||
return f"{message}.{signature_b64}"
|
||||
|
||||
def verify_jwt_token(token: str) -> dict:
|
||||
"""验证 JWT Token,返回 payload 或抛出异常"""
|
||||
import json
|
||||
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("Invalid token format")
|
||||
|
||||
header_b64, payload_b64, signature_b64 = parts
|
||||
|
||||
# 验证签名
|
||||
message = f"{header_b64}.{payload_b64}"
|
||||
expected_sig = hmac.new(JWT_SECRET.encode(), message.encode(), hashlib.sha256).digest()
|
||||
actual_sig = _b64_decode(signature_b64)
|
||||
|
||||
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||
raise ValueError("Invalid signature")
|
||||
|
||||
# 解析 payload
|
||||
payload = json.loads(_b64_decode(payload_b64))
|
||||
|
||||
# 验证过期时间
|
||||
if payload.get("exp", 0) < time.time():
|
||||
raise ValueError("Token expired")
|
||||
|
||||
return payload
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token verification failed: {str(e)}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 登录端点
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/login")
|
||||
async def admin_login(request: Request):
|
||||
"""管理员登录,返回 JWT Token"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except:
|
||||
data = {}
|
||||
|
||||
admin_key = data.get("admin_key", "")
|
||||
expire_hours = data.get("expire_hours", JWT_EXPIRE_HOURS)
|
||||
|
||||
if admin_key != ADMIN_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||
|
||||
token = create_jwt_token(expire_hours)
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"token": token,
|
||||
"expires_in": expire_hours * 3600
|
||||
})
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证当前 Token 是否有效"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="No credentials provided")
|
||||
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = verify_jwt_token(token)
|
||||
return JSONResponse(content={
|
||||
"valid": True,
|
||||
"expires_at": payload.get("exp"),
|
||||
"remaining_seconds": max(0, payload.get("exp", 0) - int(time.time()))
|
||||
})
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
|
||||
def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证 Admin 权限(支持 JWT 和直接 admin key)"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 尝试 JWT 验证
|
||||
try:
|
||||
verify_jwt_token(token)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 尝试直接 admin key
|
||||
if token == ADMIN_KEY:
|
||||
return True
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
@@ -1,226 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin 配置管理模块 - 配置、API Keys、账号管理"""
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from core.config import CONFIG, save_config, logger
|
||||
from core.auth import init_account_queue, get_queue_status, get_account_identifier
|
||||
from core.deepseek import login_deepseek_via_account
|
||||
|
||||
from .auth import verify_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Vercel 预配置
|
||||
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
|
||||
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
|
||||
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Vercel 预配置信息
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/vercel/config")
|
||||
async def get_vercel_config(_: bool = Depends(verify_admin)):
|
||||
"""获取预配置的 Vercel 信息(脱敏)"""
|
||||
return JSONResponse(content={
|
||||
"has_token": bool(VERCEL_TOKEN),
|
||||
"project_id": VERCEL_PROJECT_ID,
|
||||
"team_id": VERCEL_TEAM_ID or None,
|
||||
})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 配置管理
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/config")
|
||||
async def get_config(_: bool = Depends(verify_admin)):
|
||||
"""获取当前配置(密码脱敏)"""
|
||||
safe_config = {
|
||||
"keys": CONFIG.get("keys", []),
|
||||
"accounts": [],
|
||||
"claude_mapping": CONFIG.get("claude_mapping", {}),
|
||||
}
|
||||
|
||||
for acc in CONFIG.get("accounts", []):
|
||||
safe_acc = {
|
||||
"email": acc.get("email", ""),
|
||||
"mobile": acc.get("mobile", ""),
|
||||
"has_password": bool(acc.get("password")),
|
||||
"has_token": bool(acc.get("token")),
|
||||
"token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "",
|
||||
}
|
||||
safe_config["accounts"].append(safe_acc)
|
||||
|
||||
return JSONResponse(content=safe_config)
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
async def update_config(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""更新完整配置"""
|
||||
data = await request.json()
|
||||
|
||||
if "keys" in data:
|
||||
CONFIG["keys"] = data["keys"]
|
||||
|
||||
if "accounts" in data:
|
||||
# 保留原有密码和 token
|
||||
existing = {get_account_identifier(a): a for a in CONFIG.get("accounts", [])}
|
||||
for acc in data["accounts"]:
|
||||
acc_id = get_account_identifier(acc)
|
||||
if acc_id in existing:
|
||||
if not acc.get("password"):
|
||||
acc["password"] = existing[acc_id].get("password", "")
|
||||
if not acc.get("token"):
|
||||
acc["token"] = existing[acc_id].get("token", "")
|
||||
CONFIG["accounts"] = data["accounts"]
|
||||
init_account_queue()
|
||||
|
||||
if "claude_mapping" in data:
|
||||
CONFIG["claude_mapping"] = data["claude_mapping"]
|
||||
|
||||
save_config(CONFIG)
|
||||
return JSONResponse(content={"success": True, "message": "配置已更新"})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# API Keys 管理
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/keys")
|
||||
async def add_key(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""添加 API Key"""
|
||||
data = await request.json()
|
||||
key = data.get("key", "").strip()
|
||||
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="Key 不能为空")
|
||||
|
||||
if key in CONFIG.get("keys", []):
|
||||
raise HTTPException(status_code=400, detail="Key 已存在")
|
||||
|
||||
if "keys" not in CONFIG:
|
||||
CONFIG["keys"] = []
|
||||
CONFIG["keys"].append(key)
|
||||
save_config(CONFIG)
|
||||
|
||||
return JSONResponse(content={"success": True, "total_keys": len(CONFIG["keys"])})
|
||||
|
||||
|
||||
@router.delete("/keys/{key}")
|
||||
async def delete_key(key: str, _: bool = Depends(verify_admin)):
|
||||
"""删除 API Key"""
|
||||
if key not in CONFIG.get("keys", []):
|
||||
raise HTTPException(status_code=404, detail="Key 不存在")
|
||||
|
||||
CONFIG["keys"].remove(key)
|
||||
save_config(CONFIG)
|
||||
return JSONResponse(content={"success": True, "total_keys": len(CONFIG["keys"])})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 账号管理
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
_: bool = Depends(verify_admin)
|
||||
):
|
||||
"""获取账号列表(分页,倒序,密码脱敏)"""
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
total = len(accounts)
|
||||
|
||||
# 倒序排列
|
||||
accounts = list(reversed(accounts))
|
||||
|
||||
# 计算分页
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(100, page_size)) # 限制每页最多 100 条
|
||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
||||
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_accounts = accounts[start:end]
|
||||
|
||||
# 脱敏处理
|
||||
safe_accounts = []
|
||||
for acc in page_accounts:
|
||||
safe_acc = {
|
||||
"email": acc.get("email", ""),
|
||||
"mobile": acc.get("mobile", ""),
|
||||
"has_password": bool(acc.get("password")),
|
||||
"has_token": bool(acc.get("token")),
|
||||
"token_preview": acc.get("token", "")[:20] + "..." if acc.get("token") else "",
|
||||
}
|
||||
safe_accounts.append(safe_acc)
|
||||
|
||||
return JSONResponse(content={
|
||||
"items": safe_accounts,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/accounts")
|
||||
async def add_account(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""添加账号"""
|
||||
data = await request.json()
|
||||
email = data.get("email", "").strip()
|
||||
mobile = data.get("mobile", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
token = data.get("token", "").strip()
|
||||
|
||||
if not email and not mobile:
|
||||
raise HTTPException(status_code=400, detail="需要 email 或 mobile")
|
||||
|
||||
# 检查是否已存在
|
||||
for acc in CONFIG.get("accounts", []):
|
||||
if email and acc.get("email") == email:
|
||||
raise HTTPException(status_code=400, detail="邮箱已存在")
|
||||
if mobile and acc.get("mobile") == mobile:
|
||||
raise HTTPException(status_code=400, detail="手机号已存在")
|
||||
|
||||
new_account = {}
|
||||
if email:
|
||||
new_account["email"] = email
|
||||
if mobile:
|
||||
new_account["mobile"] = mobile
|
||||
if password:
|
||||
new_account["password"] = password
|
||||
if token:
|
||||
new_account["token"] = token
|
||||
|
||||
if "accounts" not in CONFIG:
|
||||
CONFIG["accounts"] = []
|
||||
CONFIG["accounts"].append(new_account)
|
||||
init_account_queue()
|
||||
save_config(CONFIG)
|
||||
|
||||
return JSONResponse(content={"success": True, "total_accounts": len(CONFIG["accounts"])})
|
||||
|
||||
|
||||
@router.delete("/accounts/{identifier}")
|
||||
async def delete_account(identifier: str, _: bool = Depends(verify_admin)):
|
||||
"""删除账号(通过 email 或 mobile)"""
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
for i, acc in enumerate(accounts):
|
||||
if acc.get("email") == identifier or acc.get("mobile") == identifier:
|
||||
accounts.pop(i)
|
||||
init_account_queue()
|
||||
save_config(CONFIG)
|
||||
return JSONResponse(content={"success": True, "total_accounts": len(accounts)})
|
||||
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 账号队列状态
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/queue/status")
|
||||
async def get_account_queue_status(_: bool = Depends(verify_admin)):
|
||||
"""获取账号轮询队列状态"""
|
||||
return JSONResponse(content=get_queue_status())
|
||||
@@ -1,316 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Admin Vercel 模块 - Vercel 同步和部署"""
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time as _time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from core.config import CONFIG, save_config, logger
|
||||
from core.auth import get_account_identifier, init_account_queue
|
||||
from core.deepseek import login_deepseek_via_account
|
||||
|
||||
from .auth import verify_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Vercel 预配置
|
||||
VERCEL_TOKEN = os.getenv("VERCEL_TOKEN", "")
|
||||
VERCEL_PROJECT_ID = os.getenv("VERCEL_PROJECT_ID", "")
|
||||
VERCEL_TEAM_ID = os.getenv("VERCEL_TEAM_ID", "")
|
||||
|
||||
|
||||
def _compute_config_hash() -> str:
|
||||
"""计算可同步配置的指纹哈希(仅包含 keys 和 accounts)"""
|
||||
syncable = {
|
||||
"keys": CONFIG.get("keys", []),
|
||||
"accounts": [
|
||||
{k: v for k, v in acc.items() if k != "token"}
|
||||
for acc in CONFIG.get("accounts", [])
|
||||
],
|
||||
}
|
||||
raw = json.dumps(syncable, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# API 测试(通过本地 API)
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/test")
|
||||
async def test_api(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""测试 API 调用"""
|
||||
try:
|
||||
data = await request.json()
|
||||
model = data.get("model", "deepseek-chat")
|
||||
message = data.get("message", "你好")
|
||||
api_key = data.get("api_key", "")
|
||||
|
||||
if not api_key:
|
||||
keys = CONFIG.get("keys", [])
|
||||
if not keys:
|
||||
raise HTTPException(status_code=400, detail="没有可用的 API Key")
|
||||
api_key = keys[0]
|
||||
|
||||
host = request.headers.get("host", "localhost:5001")
|
||||
scheme = "https" if "vercel" in host.lower() else "http"
|
||||
base_url = f"{scheme}://{host}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{base_url}/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": response.status_code == 200,
|
||||
"status_code": response.status_code,
|
||||
"response": response.json() if response.status_code == 200 else response.text,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"[test_api] 错误: {e}")
|
||||
return JSONResponse(content={"success": False, "error": str(e)})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Vercel 同步
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/vercel/sync")
|
||||
async def sync_to_vercel(request: Request, _: bool = Depends(verify_admin)):
|
||||
"""同步配置到 Vercel 并触发重新部署"""
|
||||
try:
|
||||
data = await request.json()
|
||||
vercel_token = data.get("vercel_token", "")
|
||||
project_id = data.get("project_id", "")
|
||||
team_id = data.get("team_id", "")
|
||||
auto_validate = data.get("auto_validate", True)
|
||||
save_vercel_credentials = data.get("save_credentials", True)
|
||||
|
||||
use_preconfig = vercel_token == "__USE_PRECONFIG__" or not vercel_token
|
||||
if use_preconfig:
|
||||
vercel_token = VERCEL_TOKEN
|
||||
if not project_id:
|
||||
project_id = VERCEL_PROJECT_ID
|
||||
if not team_id:
|
||||
team_id = VERCEL_TEAM_ID
|
||||
|
||||
if not vercel_token or not project_id:
|
||||
raise HTTPException(status_code=400, detail="需要 Vercel Token 和 Project ID")
|
||||
|
||||
# 自动验证账号
|
||||
validated_count = 0
|
||||
failed_accounts = []
|
||||
if auto_validate:
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
for acc in accounts:
|
||||
acc_id = get_account_identifier(acc)
|
||||
if not acc.get("token", "").strip():
|
||||
try:
|
||||
logger.info(f"[sync_to_vercel] 自动验证账号: {acc_id}")
|
||||
login_deepseek_via_account(acc)
|
||||
validated_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"[sync_to_vercel] 账号 {acc_id} 验证失败: {e}")
|
||||
failed_accounts.append(acc_id)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
config_json = json.dumps(CONFIG, ensure_ascii=False, separators=(",", ":"))
|
||||
config_b64 = base64.b64encode(config_json.encode("utf-8")).decode("utf-8")
|
||||
|
||||
headers = {"Authorization": f"Bearer {vercel_token}"}
|
||||
base_url = "https://api.vercel.com"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
params = {"teamId": team_id} if team_id else {}
|
||||
env_resp = await client.get(
|
||||
f"{base_url}/v9/projects/{project_id}/env",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if env_resp.status_code != 200:
|
||||
raise HTTPException(status_code=env_resp.status_code, detail=f"获取环境变量失败: {env_resp.text}")
|
||||
|
||||
env_vars = env_resp.json().get("envs", [])
|
||||
existing_env = None
|
||||
for env in env_vars:
|
||||
if env.get("key") == "DS2API_CONFIG_JSON":
|
||||
existing_env = env
|
||||
break
|
||||
|
||||
if existing_env:
|
||||
env_id = existing_env["id"]
|
||||
update_resp = await client.patch(
|
||||
f"{base_url}/v9/projects/{project_id}/env/{env_id}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json={"value": config_b64},
|
||||
)
|
||||
if update_resp.status_code not in [200, 201]:
|
||||
raise HTTPException(status_code=update_resp.status_code, detail=f"更新环境变量失败: {update_resp.text}")
|
||||
else:
|
||||
create_resp = await client.post(
|
||||
f"{base_url}/v10/projects/{project_id}/env",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json={
|
||||
"key": "DS2API_CONFIG_JSON",
|
||||
"value": config_b64,
|
||||
"type": "encrypted",
|
||||
"target": ["production", "preview"],
|
||||
},
|
||||
)
|
||||
if create_resp.status_code not in [200, 201]:
|
||||
raise HTTPException(status_code=create_resp.status_code, detail=f"创建环境变量失败: {create_resp.text}")
|
||||
|
||||
# 保存 Vercel 凭证
|
||||
saved_credentials = []
|
||||
if save_vercel_credentials and not use_preconfig:
|
||||
creds_to_save = [
|
||||
("VERCEL_TOKEN", vercel_token),
|
||||
("VERCEL_PROJECT_ID", project_id),
|
||||
]
|
||||
if team_id:
|
||||
creds_to_save.append(("VERCEL_TEAM_ID", team_id))
|
||||
|
||||
for key, value in creds_to_save:
|
||||
existing = None
|
||||
for env in env_vars:
|
||||
if env.get("key") == key:
|
||||
existing = env
|
||||
break
|
||||
|
||||
if existing:
|
||||
upd_resp = await client.patch(
|
||||
f"{base_url}/v9/projects/{project_id}/env/{existing['id']}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json={"value": value},
|
||||
)
|
||||
if upd_resp.status_code in [200, 201]:
|
||||
saved_credentials.append(key)
|
||||
else:
|
||||
crt_resp = await client.post(
|
||||
f"{base_url}/v10/projects/{project_id}/env",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json={
|
||||
"key": key,
|
||||
"value": value,
|
||||
"type": "encrypted",
|
||||
"target": ["production", "preview"],
|
||||
},
|
||||
)
|
||||
if crt_resp.status_code in [200, 201]:
|
||||
saved_credentials.append(key)
|
||||
|
||||
# 触发重新部署
|
||||
project_resp = await client.get(
|
||||
f"{base_url}/v9/projects/{project_id}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
if project_resp.status_code == 200:
|
||||
project_data = project_resp.json()
|
||||
repo = project_data.get("link", {})
|
||||
|
||||
if repo.get("type") == "github":
|
||||
deploy_resp = await client.post(
|
||||
f"{base_url}/v13/deployments",
|
||||
headers=headers,
|
||||
params=params,
|
||||
json={
|
||||
"name": project_id,
|
||||
"project": project_id,
|
||||
"target": "production",
|
||||
"gitSource": {
|
||||
"type": "github",
|
||||
"repoId": repo.get("repoId"),
|
||||
"ref": repo.get("productionBranch", "main"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if deploy_resp.status_code in [200, 201]:
|
||||
deploy_data = deploy_resp.json()
|
||||
# 记录同步哈希和时间
|
||||
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||
save_config(CONFIG)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "配置已同步,正在重新部署...",
|
||||
"deployment_url": deploy_data.get("url"),
|
||||
"validated_accounts": validated_count,
|
||||
}
|
||||
if failed_accounts:
|
||||
result["failed_accounts"] = failed_accounts
|
||||
if saved_credentials:
|
||||
result["saved_credentials"] = saved_credentials
|
||||
return JSONResponse(content=result)
|
||||
|
||||
# 环境变量已更新,但无法自动触发重新部署
|
||||
CONFIG["_vercel_sync_hash"] = _compute_config_hash()
|
||||
CONFIG["_vercel_sync_time"] = int(_time.time())
|
||||
save_config(CONFIG)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "配置已同步到 Vercel,请手动触发重新部署",
|
||||
"manual_deploy_required": True,
|
||||
"validated_accounts": validated_count,
|
||||
}
|
||||
if failed_accounts:
|
||||
result["failed_accounts"] = failed_accounts
|
||||
if saved_credentials:
|
||||
result["saved_credentials"] = saved_credentials
|
||||
return JSONResponse(content=result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[sync_to_vercel] 错误: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 同步状态查询
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/vercel/status")
|
||||
async def get_vercel_sync_status(_: bool = Depends(verify_admin)):
|
||||
"""检查当前配置与上次同步到 Vercel 的配置是否一致"""
|
||||
last_hash = CONFIG.get("_vercel_sync_hash", "")
|
||||
last_time = CONFIG.get("_vercel_sync_time", 0)
|
||||
current_hash = _compute_config_hash()
|
||||
|
||||
synced = bool(last_hash and last_hash == current_hash)
|
||||
|
||||
return JSONResponse(content={
|
||||
"synced": synced,
|
||||
"last_sync_time": last_time if last_time else None,
|
||||
"has_synced_before": bool(last_hash),
|
||||
})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 导出配置
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/export")
|
||||
async def export_config(_: bool = Depends(verify_admin)):
|
||||
"""导出完整配置(JSON 和 Base64)"""
|
||||
config_json = json.dumps(CONFIG, ensure_ascii=False, separators=(",", ":"))
|
||||
config_b64 = base64.b64encode(config_json.encode("utf-8")).decode("utf-8")
|
||||
|
||||
return JSONResponse(content={
|
||||
"json": config_json,
|
||||
"base64": config_b64,
|
||||
})
|
||||
509
routes/claude.py
509
routes/claude.py
@@ -1,509 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Claude API 路由"""
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from curl_cffi import requests as cffi_requests
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from core.config import CONFIG, logger
|
||||
from core.auth import (
|
||||
determine_mode_and_token,
|
||||
get_auth_headers,
|
||||
)
|
||||
from core.deepseek import call_completion_endpoint
|
||||
from core.session_manager import (
|
||||
create_session,
|
||||
get_pow,
|
||||
cleanup_account,
|
||||
)
|
||||
from core.models import get_model_config, get_claude_models_response
|
||||
from core.sse_parser import (
|
||||
parse_deepseek_sse_line,
|
||||
parse_sse_chunk_for_content,
|
||||
extract_content_from_chunk,
|
||||
collect_deepseek_response,
|
||||
parse_tool_calls,
|
||||
)
|
||||
from core.constants import STREAM_IDLE_TIMEOUT
|
||||
from core.utils import estimate_tokens
|
||||
from core.messages import (
|
||||
messages_prepare,
|
||||
convert_claude_to_deepseek,
|
||||
CLAUDE_DEFAULT_MODEL,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 通过 OpenAI 接口调用 Claude
|
||||
# ----------------------------------------------------------------------
|
||||
async def call_claude_via_openai(request: Request, claude_payload: dict):
|
||||
"""通过现有OpenAI接口调用Claude(实际调用DeepSeek)"""
|
||||
deepseek_payload = convert_claude_to_deepseek(claude_payload)
|
||||
|
||||
try:
|
||||
session_id = create_session(request)
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=401, detail="invalid token.")
|
||||
|
||||
pow_resp = get_pow(request)
|
||||
if not pow_resp:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Failed to get PoW (invalid token or unknown error).",
|
||||
)
|
||||
|
||||
model = deepseek_payload.get("model", "deepseek-chat")
|
||||
messages = deepseek_payload.get("messages", [])
|
||||
|
||||
# 使用会话管理器获取模型配置
|
||||
thinking_enabled, search_enabled = get_model_config(model)
|
||||
if thinking_enabled is None:
|
||||
# 默认配置
|
||||
thinking_enabled = False
|
||||
search_enabled = False
|
||||
|
||||
final_prompt = messages_prepare(messages)
|
||||
|
||||
headers = {**get_auth_headers(request), "x-ds-pow-response": pow_resp}
|
||||
payload = {
|
||||
"chat_session_id": session_id,
|
||||
"parent_message_id": None,
|
||||
"prompt": final_prompt,
|
||||
"ref_file_ids": [],
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"search_enabled": search_enabled,
|
||||
}
|
||||
|
||||
deepseek_resp = call_completion_endpoint(payload, headers, max_attempts=3)
|
||||
return deepseek_resp
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[call_claude_via_openai] 调用失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Claude 路由:模型列表
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/anthropic/v1/models")
|
||||
def list_claude_models():
|
||||
data = get_claude_models_response()
|
||||
return JSONResponse(content=data, status_code=200)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Claude 路由:/anthropic/v1/messages
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/anthropic/v1/messages")
|
||||
async def claude_messages(request: Request):
|
||||
try:
|
||||
try:
|
||||
determine_mode_and_token(request)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code, content={"error": exc.detail}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[claude_messages] determine_mode_and_token 异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500, content={"error": "Claude authentication failed."}
|
||||
)
|
||||
|
||||
req_data = await request.json()
|
||||
model = req_data.get("model")
|
||||
messages = req_data.get("messages", [])
|
||||
|
||||
if not model or not messages:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Request must include 'model' and 'messages'."
|
||||
)
|
||||
|
||||
# 标准化消息内容
|
||||
normalized_messages = []
|
||||
for message in messages:
|
||||
normalized_message = message.copy()
|
||||
if isinstance(message.get("content"), list):
|
||||
content_parts = []
|
||||
for content_block in message["content"]:
|
||||
if content_block.get("type") == "text" and "text" in content_block:
|
||||
content_parts.append(content_block["text"])
|
||||
elif content_block.get("type") == "tool_result":
|
||||
if "content" in content_block:
|
||||
content_parts.append(str(content_block["content"]))
|
||||
if content_parts:
|
||||
normalized_message["content"] = "\n".join(content_parts)
|
||||
elif isinstance(message.get("content"), list) and message["content"]:
|
||||
normalized_message["content"] = message["content"]
|
||||
else:
|
||||
normalized_message["content"] = ""
|
||||
normalized_messages.append(normalized_message)
|
||||
|
||||
tools_requested = req_data.get("tools") or []
|
||||
has_tools = len(tools_requested) > 0
|
||||
|
||||
payload = req_data.copy()
|
||||
payload["messages"] = normalized_messages.copy()
|
||||
|
||||
# 如果有工具定义,添加工具使用指导的系统消息
|
||||
if has_tools and not any(m.get("role") == "system" for m in payload["messages"]):
|
||||
tool_schemas = []
|
||||
for tool in tools_requested:
|
||||
tool_name = tool.get("name", "unknown")
|
||||
tool_desc = tool.get("description", "No description available")
|
||||
schema = tool.get("input_schema", {})
|
||||
|
||||
tool_info = f"Tool: {tool_name}\nDescription: {tool_desc}"
|
||||
if "properties" in schema:
|
||||
props = []
|
||||
required = schema.get("required", [])
|
||||
for prop_name, prop_info in schema["properties"].items():
|
||||
prop_type = prop_info.get("type", "string")
|
||||
is_req = " (required)" if prop_name in required else ""
|
||||
props.append(f" - {prop_name}: {prop_type}{is_req}")
|
||||
if props:
|
||||
tool_info += f"\nParameters:\n{chr(10).join(props)}"
|
||||
tool_schemas.append(tool_info)
|
||||
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": f"""You are Claude, a helpful AI assistant. You have access to these tools:
|
||||
|
||||
{chr(10).join(tool_schemas)}
|
||||
|
||||
When you need to use tools, you can call multiple tools in a single response. Use this format:
|
||||
|
||||
{{"tool_calls": [
|
||||
{{"name": "tool1", "input": {{"param": "value"}}}},
|
||||
{{"name": "tool2", "input": {{"param": "value"}}}}
|
||||
]}}
|
||||
|
||||
IMPORTANT: You can call multiple tools in ONE response.
|
||||
|
||||
Remember: Output ONLY the JSON, no other text. The response must start with {{ and end with ]}}""",
|
||||
}
|
||||
payload["messages"].insert(0, system_message)
|
||||
|
||||
deepseek_resp = await call_claude_via_openai(request, payload)
|
||||
if not deepseek_resp:
|
||||
raise HTTPException(status_code=500, detail="Failed to get Claude response.")
|
||||
|
||||
if deepseek_resp.status_code != 200:
|
||||
deepseek_resp.close()
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"type": "api_error", "message": "Failed to get response"}},
|
||||
)
|
||||
|
||||
# 流式响应或普通响应
|
||||
if bool(req_data.get("stream", False)):
|
||||
|
||||
def claude_sse_stream():
|
||||
# 使用导入的常量(不再本地定义)
|
||||
try:
|
||||
message_id = f"msg_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
input_tokens = sum(len(str(m.get("content", ""))) for m in messages) // 4
|
||||
output_tokens = 0
|
||||
full_response_text = ""
|
||||
last_content_time = time.time()
|
||||
has_content = False
|
||||
|
||||
|
||||
for line in deepseek_resp.iter_lines():
|
||||
current_time = time.time()
|
||||
|
||||
# 智能超时检测
|
||||
if has_content and (current_time - last_content_time) > STREAM_IDLE_TIMEOUT:
|
||||
logger.warning(f"[claude_sse_stream] 智能超时: 已有内容但 {STREAM_IDLE_TIMEOUT}s 无新数据,强制结束")
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
line_str = line.decode("utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if line_str.startswith("data:"):
|
||||
data_str = line_str[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
|
||||
# 检测内容审核/敏感词阻止
|
||||
if "error" in chunk or chunk.get("code") == "content_filter":
|
||||
logger.warning(f"[claude_sse_stream] 检测到内容过滤: {chunk}")
|
||||
break
|
||||
|
||||
if "v" in chunk and isinstance(chunk["v"], str):
|
||||
content = chunk["v"]
|
||||
# 检查是否是 FINISHED 状态
|
||||
if content == "FINISHED":
|
||||
break
|
||||
full_response_text += content
|
||||
if content:
|
||||
has_content = True
|
||||
last_content_time = current_time
|
||||
elif "v" in chunk and isinstance(chunk["v"], list):
|
||||
for item in chunk["v"]:
|
||||
if item.get("p") == "status" and item.get("v") == "FINISHED":
|
||||
break
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
continue
|
||||
|
||||
# 发送Claude格式的事件
|
||||
message_start = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [],
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": input_tokens, "output_tokens": 0},
|
||||
},
|
||||
}
|
||||
yield f"data: {json.dumps(message_start)}\n\n"
|
||||
|
||||
# 检查工具调用
|
||||
# 使用公共函数检测工具调用
|
||||
detected_tools = parse_tool_calls(full_response_text, tools_requested)
|
||||
|
||||
content_index = 0
|
||||
if detected_tools:
|
||||
stop_reason = "tool_use"
|
||||
for tool_info in detected_tools:
|
||||
tool_use_id = f"toolu_{int(time.time())}_{random.randint(1000, 9999)}_{content_index}"
|
||||
tool_name = tool_info["name"]
|
||||
tool_input = tool_info["input"]
|
||||
|
||||
yield f"data: {json.dumps({'type': 'content_block_start', 'index': content_index, 'content_block': {'type': 'tool_use', 'id': tool_use_id, 'name': tool_name, 'input': tool_input}})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'content_block_stop', 'index': content_index})}\n\n"
|
||||
|
||||
content_index += 1
|
||||
output_tokens += len(str(tool_input)) // 4
|
||||
else:
|
||||
stop_reason = "end_turn"
|
||||
if full_response_text:
|
||||
yield f"data: {json.dumps({'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': full_response_text}})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n"
|
||||
output_tokens += len(full_response_text) // 4
|
||||
|
||||
yield f"data: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': stop_reason, 'stop_sequence': None}, 'usage': {'output_tokens': output_tokens}})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[claude_sse_stream] 异常: {e}")
|
||||
error_event = {
|
||||
"type": "error",
|
||||
"error": {"type": "api_error", "message": f"Stream processing error: {str(e)}"},
|
||||
}
|
||||
yield f"data: {json.dumps(error_event)}\n\n"
|
||||
finally:
|
||||
try:
|
||||
deepseek_resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
# 注意:不在此处调用 cleanup_account,由外层 finally 统一处理
|
||||
|
||||
return StreamingResponse(
|
||||
claude_sse_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
else:
|
||||
# 非流式响应处理
|
||||
try:
|
||||
final_content = ""
|
||||
final_reasoning = ""
|
||||
|
||||
for line in deepseek_resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
line_str = line.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"[claude_messages] 行解码失败: {e}")
|
||||
continue
|
||||
|
||||
if line_str.startswith("data:"):
|
||||
data_str = line_str[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
if "v" in chunk:
|
||||
v_value = chunk["v"]
|
||||
if "p" in chunk and chunk.get("p") == "response/search_status":
|
||||
continue
|
||||
ptype = "text"
|
||||
if "p" in chunk and chunk.get("p") == "response/thinking_content":
|
||||
ptype = "thinking"
|
||||
elif "p" in chunk and chunk.get("p") == "response/content":
|
||||
ptype = "text"
|
||||
if isinstance(v_value, str):
|
||||
if ptype == "thinking":
|
||||
final_reasoning += v_value
|
||||
else:
|
||||
final_content += v_value
|
||||
elif isinstance(v_value, list):
|
||||
for item in v_value:
|
||||
if item.get("p") == "status" and item.get("v") == "FINISHED":
|
||||
break
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[claude_messages] JSON解析失败: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"[claude_messages] chunk处理失败: {e}")
|
||||
continue
|
||||
|
||||
try:
|
||||
deepseek_resp.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[claude_messages] 关闭响应异常: {e}")
|
||||
|
||||
# 检查工具调用
|
||||
detected_tools = parse_tool_calls(final_content, tools_requested)
|
||||
|
||||
# 构造响应
|
||||
claude_response = {
|
||||
"id": f"msg_{int(time.time())}_{random.randint(1000, 9999)}",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": [],
|
||||
"stop_reason": "tool_use" if detected_tools else "end_turn",
|
||||
"stop_sequence": None,
|
||||
"usage": {
|
||||
"input_tokens": len(str(normalized_messages)) // 4,
|
||||
"output_tokens": (len(final_content) + len(final_reasoning)) // 4,
|
||||
},
|
||||
}
|
||||
|
||||
if final_reasoning:
|
||||
claude_response["content"].append({"type": "thinking", "thinking": final_reasoning})
|
||||
|
||||
if detected_tools:
|
||||
for i, tool_info in enumerate(detected_tools):
|
||||
tool_use_id = f"toolu_{int(time.time())}_{random.randint(1000, 9999)}_{i}"
|
||||
claude_response["content"].append({
|
||||
"type": "tool_use",
|
||||
"id": tool_use_id,
|
||||
"name": tool_info["name"],
|
||||
"input": tool_info["input"],
|
||||
})
|
||||
else:
|
||||
if final_content or not final_reasoning:
|
||||
claude_response["content"].append({
|
||||
"type": "text",
|
||||
"text": final_content or "抱歉,没有生成有效的响应内容。",
|
||||
})
|
||||
|
||||
return JSONResponse(content=claude_response, status_code=200)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[claude_messages] 非流式响应处理异常: {e}")
|
||||
try:
|
||||
deepseek_resp.close()
|
||||
except Exception as close_e:
|
||||
logger.warning(f"[claude_messages] 关闭响应异常2: {close_e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"type": "api_error", "message": "Response processing error"}},
|
||||
)
|
||||
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": {"type": "invalid_request_error", "message": exc.detail}},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[claude_messages] 未知异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"type": "api_error", "message": "Internal Server Error"}},
|
||||
)
|
||||
finally:
|
||||
cleanup_account(request)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Claude 路由:/anthropic/v1/messages/count_tokens
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/anthropic/v1/messages/count_tokens")
|
||||
async def claude_count_tokens(request: Request):
|
||||
try:
|
||||
try:
|
||||
determine_mode_and_token(request)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
|
||||
except Exception as exc:
|
||||
logger.error(f"[claude_count_tokens] determine_mode_and_token 异常: {exc}")
|
||||
return JSONResponse(status_code=500, content={"error": "Claude authentication failed."})
|
||||
|
||||
req_data = await request.json()
|
||||
model = req_data.get("model")
|
||||
messages = req_data.get("messages", [])
|
||||
system = req_data.get("system", "")
|
||||
|
||||
if not model or not messages:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Request must include 'model' and 'messages'."
|
||||
)
|
||||
|
||||
input_tokens = 0
|
||||
|
||||
if system:
|
||||
input_tokens += estimate_tokens(system)
|
||||
|
||||
for message in messages:
|
||||
content = message.get("content", "")
|
||||
input_tokens += 2 # 角色标记
|
||||
|
||||
if isinstance(content, list):
|
||||
for content_block in content:
|
||||
if isinstance(content_block, dict):
|
||||
if content_block.get("type") == "text":
|
||||
input_tokens += estimate_tokens(content_block.get("text", ""))
|
||||
elif content_block.get("type") == "tool_result":
|
||||
input_tokens += estimate_tokens(content_block.get("content", ""))
|
||||
else:
|
||||
input_tokens += estimate_tokens(str(content_block))
|
||||
else:
|
||||
input_tokens += estimate_tokens(str(content_block))
|
||||
else:
|
||||
input_tokens += estimate_tokens(content)
|
||||
|
||||
tools = req_data.get("tools", [])
|
||||
if tools:
|
||||
for tool in tools:
|
||||
input_tokens += estimate_tokens(tool.get("name", ""))
|
||||
input_tokens += estimate_tokens(tool.get("description", ""))
|
||||
input_schema = tool.get("input_schema", {})
|
||||
input_tokens += estimate_tokens(json.dumps(input_schema, ensure_ascii=False))
|
||||
|
||||
response = {"input_tokens": max(1, input_tokens)}
|
||||
return JSONResponse(content=response, status_code=200)
|
||||
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": {"type": "invalid_request_error", "message": exc.detail}},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[claude_count_tokens] 未知异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": {"type": "api_error", "message": "Internal Server Error"}},
|
||||
)
|
||||
308
routes/home.py
308
routes/home.py
@@ -1,308 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""首页和 WebUI 路由"""
|
||||
import os
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
|
||||
from core.config import STATIC_ADMIN_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 首页 HTML(内嵌避免依赖模板目录)
|
||||
WELCOME_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DS2API - DeepSeek to OpenAI API</title>
|
||||
<meta name="description" content="DS2API - 将 DeepSeek 网页版转换为 OpenAI 兼容 API">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #f59e0b;
|
||||
--primary-glow: rgba(245, 158, 11, 0.4);
|
||||
--secondary: #ef4444;
|
||||
--bg: #030712;
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-main: #f9fafb;
|
||||
--text-dim: #9ca3af;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated Background */
|
||||
.bg-glow {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(245, 158, 11, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(239, 68, 68, 0.05) 0%, transparent 40%);
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
filter: blur(80px);
|
||||
opacity: 0.15;
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
animation: move 20s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
from { transform: translate(-10%, -10%) scale(1); }
|
||||
to { transform: translate(10%, 10%) scale(1.1); }
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 3rem;
|
||||
animation: fadeInUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: clamp(3rem, 10vw, 5rem);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -2px;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-dim);
|
||||
font-size: 1.25rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 4rem;
|
||||
flex-wrap: wrap;
|
||||
animation: fadeInUp 0.8s ease-out 0.2s backwards;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow: 0 8px 25px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--card-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
animation: fadeInUp 0.8s ease-out 0.4s backwards;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
animation: fadeInUp 0.8s ease-out 0.6s backwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.logo { font-size: 3.5rem; }
|
||||
.container { padding: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow"></div>
|
||||
<div class="blob" style="top: 10%; left: 15%;"></div>
|
||||
<div class="blob" style="bottom: 10%; right: 15%; animation-delay: -5s;"></div>
|
||||
|
||||
<div class="container">
|
||||
<header class="logo-section">
|
||||
<div class="logo">DS2API</div>
|
||||
<p class="subtitle">DeepSeek to OpenAI & Claude Compatible API Interface</p>
|
||||
</header>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/admin" class="btn btn-primary">
|
||||
<span>🎛️</span> 管理面板
|
||||
</a>
|
||||
<a href="/v1/models" class="btn btn-secondary">
|
||||
<span>📡</span> API 状态
|
||||
</a>
|
||||
<a href="https://github.com/CJackHwang/ds2api" class="btn btn-secondary" target="_blank">
|
||||
<span>📦</span> GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚀</span>
|
||||
<h3>全面兼容</h3>
|
||||
<p>完美适配 OpenAI 与 Claude API 格式,无缝集成现有工具。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚖️</span>
|
||||
<h3>负载均衡</h3>
|
||||
<p>内置智能轮询机制,支持多账号并发,稳定高效。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🧠</span>
|
||||
<h3>深度思考</h3>
|
||||
<p>完整支持 推理过程输出,让思考可见。</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔍</span>
|
||||
<h3>联网搜索</h3>
|
||||
<p>集成 DeepSeek 原生搜索能力,获取最新实时资讯。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 DS2API Project. Designed for flexibility & performance.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def index(request: Request):
|
||||
return HTMLResponse(content=WELCOME_HTML)
|
||||
|
||||
|
||||
@router.get("/admin")
|
||||
@router.get("/admin/{path:path}")
|
||||
async def webui(request: Request, path: str = ""):
|
||||
"""提供 WebUI 静态文件"""
|
||||
# 检查 static/admin 目录是否存在
|
||||
if not os.path.isdir(STATIC_ADMIN_DIR):
|
||||
return HTMLResponse(
|
||||
content="<h1>WebUI not built</h1><p>Run <code>cd webui && npm run build</code> first.</p>",
|
||||
status_code=404
|
||||
)
|
||||
|
||||
# 如果请求的是具体文件(如 js, css)
|
||||
if path and "." in path:
|
||||
file_path = os.path.join(STATIC_ADMIN_DIR, path)
|
||||
if os.path.isfile(file_path):
|
||||
cache_control = "public, max-age=31536000, immutable"
|
||||
if path.startswith("assets/"):
|
||||
headers = {"Cache-Control": cache_control}
|
||||
else:
|
||||
headers = {"Cache-Control": "no-store, must-revalidate"}
|
||||
return FileResponse(file_path, headers=headers)
|
||||
return HTMLResponse(content="Not Found", status_code=404)
|
||||
|
||||
# 否则返回 index.html(SPA 路由)
|
||||
index_path = os.path.join(STATIC_ADMIN_DIR, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
headers = {"Cache-Control": "no-store, must-revalidate"}
|
||||
return FileResponse(index_path, headers=headers)
|
||||
|
||||
return HTMLResponse(content="index.html not found", status_code=404)
|
||||
|
||||
607
routes/openai.py
607
routes/openai.py
@@ -1,607 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OpenAI 兼容路由"""
|
||||
import json
|
||||
import queue
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from curl_cffi import requests as cffi_requests
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from core.config import CONFIG, logger
|
||||
from core.auth import (
|
||||
determine_mode_and_token,
|
||||
get_auth_headers,
|
||||
release_account,
|
||||
)
|
||||
from core.deepseek import call_completion_endpoint
|
||||
from core.session_manager import (
|
||||
create_session,
|
||||
get_pow,
|
||||
cleanup_account,
|
||||
)
|
||||
from core.models import get_model_config, get_openai_models_response
|
||||
from core.sse_parser import (
|
||||
parse_deepseek_sse_line,
|
||||
parse_sse_chunk_for_content,
|
||||
extract_content_from_chunk,
|
||||
extract_content_recursive,
|
||||
should_filter_citation,
|
||||
parse_tool_calls,
|
||||
format_openai_tool_calls,
|
||||
)
|
||||
from core.constants import (
|
||||
KEEP_ALIVE_TIMEOUT,
|
||||
STREAM_IDLE_TIMEOUT,
|
||||
MAX_KEEPALIVE_COUNT,
|
||||
)
|
||||
from core.messages import messages_prepare
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 预编译正则表达式(性能优化)
|
||||
_CITATION_PATTERN = re.compile(r"^\[citation:")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 路由:/v1/models
|
||||
# ----------------------------------------------------------------------
|
||||
@router.get("/v1/models")
|
||||
def list_models():
|
||||
data = get_openai_models_response()
|
||||
return JSONResponse(content=data, status_code=200)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 路由:/v1/chat/completions
|
||||
# ----------------------------------------------------------------------
|
||||
@router.post("/v1/chat/completions")
|
||||
async def chat_completions(request: Request):
|
||||
try:
|
||||
# 处理 token 相关逻辑,若登录失败则直接返回错误响应
|
||||
try:
|
||||
determine_mode_and_token(request)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code, content={"error": exc.detail}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"[chat_completions] determine_mode_and_token 异常: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500, content={"error": "Account login failed."}
|
||||
)
|
||||
|
||||
req_data = await request.json()
|
||||
model = req_data.get("model")
|
||||
messages = req_data.get("messages", [])
|
||||
if not model or not messages:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Request must include 'model' and 'messages'."
|
||||
)
|
||||
|
||||
# 解析工具调用参数(OpenAI 格式)
|
||||
tools_requested = req_data.get("tools") or []
|
||||
has_tools = len(tools_requested) > 0
|
||||
|
||||
# 如果有工具定义,构建工具提示并注入到消息中
|
||||
messages_with_tools = messages.copy()
|
||||
if has_tools:
|
||||
tool_schemas = []
|
||||
for tool in tools_requested:
|
||||
# OpenAI 格式: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
|
||||
func = tool.get("function", tool) # 兼容简化格式
|
||||
tool_name = func.get("name", "unknown")
|
||||
tool_desc = func.get("description", "No description available")
|
||||
schema = func.get("parameters", {})
|
||||
|
||||
tool_info = f"Tool: {tool_name}\nDescription: {tool_desc}"
|
||||
if "properties" in schema:
|
||||
props = []
|
||||
required = schema.get("required", [])
|
||||
for prop_name, prop_info in schema["properties"].items():
|
||||
prop_type = prop_info.get("type", "string")
|
||||
is_req = " (required)" if prop_name in required else ""
|
||||
props.append(f" - {prop_name}: {prop_type}{is_req}")
|
||||
if props:
|
||||
tool_info += f"\nParameters:\n" + "\n".join(props)
|
||||
tool_schemas.append(tool_info)
|
||||
|
||||
# 检查是否已有系统消息
|
||||
has_system = any(m.get("role") == "system" for m in messages_with_tools)
|
||||
tool_prompt = f"""You have access to these tools:
|
||||
|
||||
{chr(10).join(tool_schemas)}
|
||||
|
||||
When you need to use tools, output ONLY this JSON format (no other text):
|
||||
{{"tool_calls": [
|
||||
{{"name": "tool_name", "input": {{"param": "value"}}}}
|
||||
]}}
|
||||
|
||||
IMPORTANT: If calling tools, output ONLY the JSON. The response must start with {{ and end with }}"""
|
||||
|
||||
if has_system:
|
||||
# 追加到现有系统消息
|
||||
for i, m in enumerate(messages_with_tools):
|
||||
if m.get("role") == "system":
|
||||
messages_with_tools[i] = {
|
||||
"role": "system",
|
||||
"content": m.get("content", "") + "\n\n" + tool_prompt
|
||||
}
|
||||
break
|
||||
else:
|
||||
# 添加新的系统消息
|
||||
messages_with_tools.insert(0, {"role": "system", "content": tool_prompt})
|
||||
|
||||
# 使用会话管理器获取模型配置
|
||||
thinking_enabled, search_enabled = get_model_config(model)
|
||||
if thinking_enabled is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail=f"Model '{model}' is not available."
|
||||
)
|
||||
|
||||
# 使用 messages_prepare 函数构造最终 prompt(使用带工具提示的消息)
|
||||
final_prompt = messages_prepare(messages_with_tools)
|
||||
session_id = create_session(request)
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=401, detail="invalid token.")
|
||||
|
||||
pow_resp = get_pow(request)
|
||||
if not pow_resp:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Failed to get PoW (invalid token or unknown error).",
|
||||
)
|
||||
|
||||
headers = {**get_auth_headers(request), "x-ds-pow-response": pow_resp}
|
||||
payload = {
|
||||
"chat_session_id": session_id,
|
||||
"parent_message_id": None,
|
||||
"prompt": final_prompt,
|
||||
"ref_file_ids": [],
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"search_enabled": search_enabled,
|
||||
}
|
||||
|
||||
deepseek_resp = call_completion_endpoint(payload, headers, max_attempts=3)
|
||||
if not deepseek_resp:
|
||||
raise HTTPException(status_code=500, detail="Failed to get completion.")
|
||||
created_time = int(time.time())
|
||||
completion_id = f"{session_id}"
|
||||
|
||||
# 流式响应(SSE)或普通响应
|
||||
if bool(req_data.get("stream", False)):
|
||||
if deepseek_resp.status_code != 200:
|
||||
deepseek_resp.close()
|
||||
return JSONResponse(
|
||||
content=deepseek_resp.content, status_code=deepseek_resp.status_code
|
||||
)
|
||||
|
||||
def sse_stream():
|
||||
# 使用导入的常量(不再本地定义)
|
||||
try:
|
||||
final_text = ""
|
||||
final_thinking = ""
|
||||
first_chunk_sent = False
|
||||
result_queue = queue.Queue()
|
||||
last_send_time = time.time()
|
||||
last_content_time = time.time() # 最后收到有效内容的时间
|
||||
keepalive_count = 0 # 连续 keepalive 计数
|
||||
has_content = False # 是否收到过内容
|
||||
stream_finished = False # 是否已发送过结束标记
|
||||
|
||||
def process_data():
|
||||
"""处理 DeepSeek SSE 数据流 - 使用 sse_parser 模块"""
|
||||
nonlocal has_content
|
||||
current_fragment_type = "thinking" if thinking_enabled else "text"
|
||||
logger.info(f"[sse_stream] 开始处理数据流, session_id={session_id}")
|
||||
|
||||
try:
|
||||
for raw_line in deepseek_resp.iter_lines():
|
||||
# 解码行
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"[sse_stream] 解码失败: {e}")
|
||||
result_queue.put({"choices": [{"index": 0, "delta": {"content": "解码失败,请稍候再试", "type": "text"}}]})
|
||||
result_queue.put(None)
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_str = line[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
result_queue.put(None)
|
||||
break
|
||||
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
|
||||
# 检测内容审核/敏感词阻止
|
||||
if "error" in chunk or chunk.get("code") == "content_filter":
|
||||
logger.warning(f"[sse_stream] 检测到内容过滤: {chunk}")
|
||||
result_queue.put({"choices": [{"index": 0, "finish_reason": "content_filter"}]})
|
||||
result_queue.put(None)
|
||||
return
|
||||
|
||||
# 使用 sse_parser 模块解析内容
|
||||
contents, is_finished, new_fragment_type = parse_sse_chunk_for_content(
|
||||
chunk, thinking_enabled, current_fragment_type
|
||||
)
|
||||
current_fragment_type = new_fragment_type
|
||||
|
||||
if is_finished:
|
||||
result_queue.put({"choices": [{"index": 0, "finish_reason": "stop"}]})
|
||||
result_queue.put(None)
|
||||
return
|
||||
|
||||
# 处理提取的内容
|
||||
for content_text, content_type in contents:
|
||||
if content_text:
|
||||
has_content = True
|
||||
unified_chunk = {
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": content_text, "type": content_type}
|
||||
}],
|
||||
"model": "",
|
||||
"chunk_token_usage": len(content_text) // 4,
|
||||
"created": 0,
|
||||
"message_id": -1,
|
||||
"parent_id": -1
|
||||
}
|
||||
result_queue.put(unified_chunk)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[sse_stream] 无法解析: {data_str[:100]}, 错误: {e}")
|
||||
result_queue.put({"choices": [{"index": 0, "delta": {"content": "解析失败,请稍候再试", "type": "text"}}]})
|
||||
result_queue.put(None)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[sse_stream] 错误: {e}")
|
||||
result_queue.put({"choices": [{"index": 0, "delta": {"content": "服务器错误,请稍候再试", "type": "text"}}]})
|
||||
result_queue.put(None)
|
||||
finally:
|
||||
deepseek_resp.close()
|
||||
|
||||
|
||||
process_thread = threading.Thread(target=process_data)
|
||||
process_thread.start()
|
||||
|
||||
while True:
|
||||
current_time = time.time()
|
||||
|
||||
# 智能超时检测:如果已有内容且长时间无新数据,强制结束
|
||||
if has_content and (current_time - last_content_time) > STREAM_IDLE_TIMEOUT:
|
||||
logger.warning(f"[sse_stream] 智能超时: 已有内容但 {STREAM_IDLE_TIMEOUT}s 无新数据,强制结束")
|
||||
break
|
||||
|
||||
# 连续 keepalive 检测:如果已有内容且连续多次 keepalive,强制结束
|
||||
if has_content and keepalive_count >= MAX_KEEPALIVE_COUNT:
|
||||
logger.warning(f"[sse_stream] 智能超时: 连续 {MAX_KEEPALIVE_COUNT} 次 keepalive,强制结束")
|
||||
break
|
||||
|
||||
if current_time - last_send_time >= KEEP_ALIVE_TIMEOUT:
|
||||
yield ": keep-alive\n\n"
|
||||
last_send_time = current_time
|
||||
keepalive_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk = result_queue.get(timeout=0.05)
|
||||
keepalive_count = 0 # 重置 keepalive 计数
|
||||
|
||||
if chunk is None:
|
||||
prompt_tokens = len(final_prompt) // 4
|
||||
thinking_tokens = len(final_thinking) // 4
|
||||
completion_tokens = len(final_text) // 4
|
||||
usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": thinking_tokens + completion_tokens,
|
||||
"total_tokens": prompt_tokens + thinking_tokens + completion_tokens,
|
||||
"completion_tokens_details": {"reasoning_tokens": thinking_tokens},
|
||||
}
|
||||
|
||||
# 检测工具调用
|
||||
detected_tools = []
|
||||
finish_reason = "stop"
|
||||
if has_tools:
|
||||
detected_tools = parse_tool_calls(final_text, [{"name": t.get("function", t).get("name")} for t in tools_requested])
|
||||
if detected_tools:
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if detected_tools:
|
||||
# 发送工具调用响应
|
||||
tool_calls_data = format_openai_tool_calls(detected_tools)
|
||||
tool_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{"delta": {"tool_calls": tool_calls_data}, "index": 0}],
|
||||
}
|
||||
yield f"data: {json.dumps(tool_chunk, ensure_ascii=False)}\n\n"
|
||||
|
||||
finish_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{"delta": {}, "index": 0, "finish_reason": finish_reason}],
|
||||
"usage": usage,
|
||||
}
|
||||
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
last_send_time = current_time
|
||||
stream_finished = True
|
||||
break
|
||||
|
||||
new_choices = []
|
||||
for choice in chunk.get("choices", []):
|
||||
delta = choice.get("delta", {})
|
||||
ctype = delta.get("type")
|
||||
ctext = delta.get("content", "")
|
||||
if choice.get("finish_reason") == "backend_busy":
|
||||
ctext = "服务器繁忙,请稍候再试"
|
||||
if choice.get("finish_reason") == "content_filter":
|
||||
# 内容过滤,正常结束
|
||||
pass
|
||||
if search_enabled and ctext.startswith("[citation:"):
|
||||
ctext = ""
|
||||
if ctype == "thinking":
|
||||
if thinking_enabled:
|
||||
final_thinking += ctext
|
||||
else:
|
||||
# 非 thinking 内容都作为普通文本处理(包括 ctype=None 或 "text")
|
||||
final_text += ctext
|
||||
delta_obj = {}
|
||||
if not first_chunk_sent:
|
||||
delta_obj["role"] = "assistant"
|
||||
first_chunk_sent = True
|
||||
if ctype == "thinking":
|
||||
if thinking_enabled:
|
||||
delta_obj["reasoning_content"] = ctext
|
||||
else:
|
||||
# 非 thinking 内容都作为 content 输出
|
||||
if ctext:
|
||||
delta_obj["content"] = ctext
|
||||
if delta_obj:
|
||||
new_choices.append({"delta": delta_obj, "index": choice.get("index", 0)})
|
||||
|
||||
if new_choices:
|
||||
last_content_time = current_time # 更新最后内容时间
|
||||
out_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": new_choices,
|
||||
}
|
||||
yield f"data: {json.dumps(out_chunk, ensure_ascii=False)}\n\n"
|
||||
last_send_time = current_time
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
# 如果是超时退出且尚未发送结束标记,补发结束标记
|
||||
if has_content and not stream_finished:
|
||||
prompt_tokens = len(final_prompt) // 4
|
||||
thinking_tokens = len(final_thinking) // 4
|
||||
completion_tokens = len(final_text) // 4
|
||||
usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": thinking_tokens + completion_tokens,
|
||||
"total_tokens": prompt_tokens + thinking_tokens + completion_tokens,
|
||||
"completion_tokens_details": {"reasoning_tokens": thinking_tokens},
|
||||
}
|
||||
|
||||
# 检测工具调用
|
||||
detected_tools = []
|
||||
finish_reason = "stop"
|
||||
if has_tools:
|
||||
detected_tools = parse_tool_calls(final_text, [{"name": t.get("function", t).get("name")} for t in tools_requested])
|
||||
if detected_tools:
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if detected_tools:
|
||||
tool_calls_data = format_openai_tool_calls(detected_tools)
|
||||
tool_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{"delta": {"tool_calls": tool_calls_data}, "index": 0}],
|
||||
}
|
||||
yield f"data: {json.dumps(tool_chunk, ensure_ascii=False)}\n\n"
|
||||
|
||||
finish_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{"delta": {}, "index": 0, "finish_reason": finish_reason}],
|
||||
"usage": usage,
|
||||
}
|
||||
yield f"data: {json.dumps(finish_chunk, ensure_ascii=False)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[sse_stream] 异常: {e}")
|
||||
# 注意:不在此处调用 cleanup_account,由外层 finally 统一处理
|
||||
|
||||
return StreamingResponse(
|
||||
sse_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
)
|
||||
else:
|
||||
# 非流式响应处理
|
||||
think_list = []
|
||||
text_list = []
|
||||
result = None
|
||||
|
||||
data_queue = queue.Queue()
|
||||
|
||||
def collect_data():
|
||||
nonlocal result
|
||||
current_fragment_type = "thinking" if thinking_enabled else "text"
|
||||
try:
|
||||
for raw_line in deepseek_resp.iter_lines():
|
||||
chunk = parse_deepseek_sse_line(raw_line)
|
||||
if not chunk:
|
||||
continue
|
||||
if chunk.get("type") == "done":
|
||||
data_queue.put(None)
|
||||
break
|
||||
try:
|
||||
contents, is_finished, new_fragment_type = parse_sse_chunk_for_content(
|
||||
chunk, thinking_enabled, current_fragment_type
|
||||
)
|
||||
current_fragment_type = new_fragment_type
|
||||
if is_finished:
|
||||
final_reasoning = "".join(think_list)
|
||||
final_content = "".join(text_list)
|
||||
prompt_tokens = len(final_prompt) // 4
|
||||
reasoning_tokens = len(final_reasoning) // 4
|
||||
completion_tokens = len(final_content) // 4
|
||||
|
||||
# 检测工具调用
|
||||
detected_tools = []
|
||||
finish_reason = "stop"
|
||||
if has_tools:
|
||||
detected_tools = parse_tool_calls(final_content, [{"name": t.get("function", t).get("name")} for t in tools_requested])
|
||||
if detected_tools:
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
# 构建 message 对象
|
||||
message_obj = {
|
||||
"role": "assistant",
|
||||
"content": final_content if not detected_tools else None,
|
||||
}
|
||||
# 只有启用思考模式时才包含 reasoning_content
|
||||
if thinking_enabled and final_reasoning:
|
||||
message_obj["reasoning_content"] = final_reasoning
|
||||
# 添加工具调用
|
||||
if detected_tools:
|
||||
tool_calls_data = format_openai_tool_calls(detected_tools)
|
||||
message_obj["tool_calls"] = tool_calls_data
|
||||
message_obj["content"] = None
|
||||
|
||||
result = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message_obj,
|
||||
"finish_reason": finish_reason,
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": reasoning_tokens + completion_tokens,
|
||||
"total_tokens": prompt_tokens + reasoning_tokens + completion_tokens,
|
||||
"completion_tokens_details": {"reasoning_tokens": reasoning_tokens},
|
||||
},
|
||||
}
|
||||
data_queue.put("DONE")
|
||||
return
|
||||
|
||||
for content_text, content_type in contents:
|
||||
if should_filter_citation(content_text, search_enabled):
|
||||
continue
|
||||
if content_type == "thinking":
|
||||
think_list.append(content_text)
|
||||
else:
|
||||
text_list.append(content_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"[collect_data] 无法解析: {chunk}, 错误: {e}")
|
||||
text_list.append("解析失败,请稍候再试")
|
||||
data_queue.put(None)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"[collect_data] 错误: {e}")
|
||||
text_list.append("处理失败,请稍候再试")
|
||||
data_queue.put(None)
|
||||
finally:
|
||||
deepseek_resp.close()
|
||||
if result is None:
|
||||
final_content = "".join(text_list)
|
||||
final_reasoning = "".join(think_list)
|
||||
prompt_tokens = len(final_prompt) // 4
|
||||
reasoning_tokens = len(final_reasoning) // 4
|
||||
completion_tokens = len(final_content) // 4
|
||||
|
||||
# 检测工具调用
|
||||
detected_tools = []
|
||||
finish_reason = "stop"
|
||||
if has_tools:
|
||||
detected_tools = parse_tool_calls(final_content, [{"name": t.get("function", t).get("name")} for t in tools_requested])
|
||||
if detected_tools:
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
# 构建 message 对象
|
||||
message_obj = {
|
||||
"role": "assistant",
|
||||
"content": final_content if not detected_tools else None,
|
||||
}
|
||||
# 只有启用思考模式时才包含 reasoning_content
|
||||
if thinking_enabled and final_reasoning:
|
||||
message_obj["reasoning_content"] = final_reasoning
|
||||
# 添加工具调用
|
||||
if detected_tools:
|
||||
tool_calls_data = format_openai_tool_calls(detected_tools)
|
||||
message_obj["tool_calls"] = tool_calls_data
|
||||
message_obj["content"] = None
|
||||
|
||||
result = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message_obj,
|
||||
"finish_reason": finish_reason,
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": reasoning_tokens + completion_tokens,
|
||||
"total_tokens": prompt_tokens + reasoning_tokens + completion_tokens,
|
||||
},
|
||||
}
|
||||
data_queue.put("DONE")
|
||||
|
||||
collect_thread = threading.Thread(target=collect_data)
|
||||
collect_thread.start()
|
||||
|
||||
def generate():
|
||||
last_send_time = time.time()
|
||||
while True:
|
||||
current_time = time.time()
|
||||
if current_time - last_send_time >= KEEP_ALIVE_TIMEOUT:
|
||||
yield ""
|
||||
last_send_time = current_time
|
||||
if not collect_thread.is_alive() and result is not None:
|
||||
yield json.dumps(result)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
return StreamingResponse(generate(), media_type="application/json")
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
|
||||
except Exception as exc:
|
||||
logger.error(f"[chat_completions] 未知异常: {exc}")
|
||||
return JSONResponse(status_code=500, content={"error": "Internal Server Error"})
|
||||
finally:
|
||||
cleanup_account(request)
|
||||
138
tests/README.md
138
tests/README.md
@@ -1,138 +0,0 @@
|
||||
# DS2API 测试文档
|
||||
|
||||
## 测试文件结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── __init__.py # 测试模块初始化
|
||||
├── test_unit.py # 单元测试(不依赖网络)
|
||||
├── test_all.py # API 集成测试
|
||||
├── test_accounts.py # 账号池测试
|
||||
└── run_tests.sh # 测试运行脚本
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
```bash
|
||||
# 使用脚本
|
||||
./tests/run_tests.sh all
|
||||
|
||||
# 或直接运行
|
||||
python3 tests/test_unit.py # 单元测试
|
||||
python3 tests/test_all.py # API 测试
|
||||
```
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
python3 tests/test_unit.py
|
||||
```
|
||||
|
||||
测试内容:
|
||||
- 配置加载
|
||||
- 消息处理(`messages_prepare`)
|
||||
- WASM 缓存
|
||||
- 模型配置获取
|
||||
- 正则表达式模式
|
||||
- 流式响应解析
|
||||
- **工具调用解析**(`parse_tool_calls`)
|
||||
- **Token 估算**
|
||||
|
||||
### 运行 API 集成测试
|
||||
|
||||
```bash
|
||||
# 完整测试
|
||||
python3 tests/test_all.py
|
||||
|
||||
# 快速测试(跳过耗时测试)
|
||||
python3 tests/test_all.py --quick
|
||||
|
||||
# 指定端点
|
||||
python3 tests/test_all.py --endpoint http://your-server.com
|
||||
|
||||
# 详细输出
|
||||
python3 tests/test_all.py --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
|
||||
| 类别 | 测试项 |
|
||||
|-----|--------|
|
||||
| 基础 | 服务健康检查 |
|
||||
| OpenAI | 模型列表、非流式对话、流式对话、无效模型处理、认证错误、Reasoner 模式 |
|
||||
| Claude | 模型列表、非流式消息、流式消息、Token 计数 |
|
||||
| 高级 | 多轮对话、长输入处理 |
|
||||
| **工具调用** | OpenAI 工具调用(流式/非流式)、Claude 工具调用 |
|
||||
| **搜索模式** | OpenAI 搜索模式 |
|
||||
|
||||
### 运行账号测试
|
||||
|
||||
```bash
|
||||
# 测试所有账号登录
|
||||
python3 tests/test_accounts.py --login
|
||||
|
||||
# 测试账号轮换
|
||||
python3 tests/test_accounts.py --rotation
|
||||
|
||||
# 运行所有
|
||||
python3 tests/test_accounts.py --all
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
测试使用 `config.json` 中的配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"keys": ["test-api-key-001"],
|
||||
"accounts": [
|
||||
{"email": "xxx@gmail.com", "password": "xxx", "token": ""}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 预期输出
|
||||
|
||||
### 单元测试
|
||||
|
||||
```
|
||||
Ran 32 tests in 9.0s
|
||||
OK
|
||||
```
|
||||
|
||||
### API 测试
|
||||
|
||||
```
|
||||
📊 测试报告
|
||||
总计: 18 个测试
|
||||
✅ 通过: 18
|
||||
❌ 失败: 0
|
||||
⏱️ 耗时: ~60s
|
||||
📈 通过率: 100.0%
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 服务未运行
|
||||
|
||||
```
|
||||
⚠️ 服务未运行,跳过其他测试
|
||||
```
|
||||
|
||||
解决:先启动服务 `python dev.py`
|
||||
|
||||
### 认证失败
|
||||
|
||||
```
|
||||
❌ 失败: 状态码: 401
|
||||
```
|
||||
|
||||
解决:检查 `config.json` 中的 API key 和账号配置
|
||||
|
||||
### 流式测试超时
|
||||
|
||||
可能是 DeepSeek API 响应慢,可以尝试:
|
||||
- 使用 `--quick` 模式
|
||||
- 增加测试超时时间
|
||||
@@ -1 +0,0 @@
|
||||
# DS2API 测试模块
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/bin/bash
|
||||
# DS2API 测试运行器
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "=================================================="
|
||||
echo " 🧪 DS2API 测试套件"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
|
||||
# 颜色
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 检查服务是否运行
|
||||
check_service() {
|
||||
echo -e "${YELLOW}检查服务状态...${NC}"
|
||||
if curl -s http://localhost:5001/ > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ 服务运行中${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ 服务未运行${NC}"
|
||||
echo "请先启动服务: python dev.py"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 运行单元测试
|
||||
run_unit_tests() {
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " 📋 单元测试"
|
||||
echo "=================================================="
|
||||
python3 -m pytest tests/test_unit.py -v --tb=short 2>/dev/null || python3 tests/test_unit.py
|
||||
}
|
||||
|
||||
# 运行 API 测试
|
||||
run_api_tests() {
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " 🌐 API 集成测试"
|
||||
echo "=================================================="
|
||||
python3 tests/test_all.py "$@"
|
||||
}
|
||||
|
||||
# 运行账号测试
|
||||
run_account_tests() {
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " 🔑 账号测试"
|
||||
echo "=================================================="
|
||||
python3 tests/test_accounts.py --all
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
echo "用法: $0 [选项]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " unit 只运行单元测试"
|
||||
echo " api 只运行 API 测试"
|
||||
echo " api --quick 快速 API 测试"
|
||||
echo " accounts 只运行账号测试"
|
||||
echo " all 运行所有测试"
|
||||
echo " help 显示此帮助"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 unit"
|
||||
echo " $0 api --quick"
|
||||
echo " $0 all"
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
case "${1:-all}" in
|
||||
unit)
|
||||
run_unit_tests
|
||||
;;
|
||||
api)
|
||||
if check_service; then
|
||||
shift
|
||||
run_api_tests "$@"
|
||||
fi
|
||||
;;
|
||||
accounts)
|
||||
run_account_tests
|
||||
;;
|
||||
all)
|
||||
run_unit_tests
|
||||
echo ""
|
||||
if check_service; then
|
||||
run_api_tests --quick
|
||||
fi
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo "未知选项: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=================================================="
|
||||
echo " ✨ 测试完成"
|
||||
echo "=================================================="
|
||||
@@ -1,189 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API 账号池测试
|
||||
|
||||
测试账号登录和轮换功能
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountTestResult:
|
||||
email: str
|
||||
login_success: bool
|
||||
has_token: bool
|
||||
token_preview: str
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def test_account_login(account: dict) -> AccountTestResult:
|
||||
"""测试单个账号登录"""
|
||||
from core.deepseek import login_deepseek_via_account
|
||||
from core.config import logger
|
||||
|
||||
email = account.get("email", account.get("mobile", "unknown"))
|
||||
print(f"\n📧 测试账号: {email}")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
login_deepseek_via_account(account)
|
||||
token = account.get("token", "")
|
||||
|
||||
if token:
|
||||
print(f"✅ 登录成功")
|
||||
print(f" Token: {token[:30]}...{token[-10:]}")
|
||||
return AccountTestResult(
|
||||
email=email,
|
||||
login_success=True,
|
||||
has_token=True,
|
||||
token_preview=f"{token[:30]}...{token[-10:]}"
|
||||
)
|
||||
else:
|
||||
print(f"⚠️ 登录完成但无 Token")
|
||||
return AccountTestResult(
|
||||
email=email,
|
||||
login_success=True,
|
||||
has_token=False,
|
||||
token_preview=""
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ 登录失败: {e}")
|
||||
return AccountTestResult(
|
||||
email=email,
|
||||
login_success=False,
|
||||
has_token=False,
|
||||
token_preview="",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
|
||||
def test_account_pool():
|
||||
"""测试整个账号池"""
|
||||
from core.config import CONFIG, logger
|
||||
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
|
||||
if not accounts:
|
||||
print("⚠️ 配置中没有账号")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 🔑 DS2API 账号池测试")
|
||||
print("=" * 60)
|
||||
print(f"共 {len(accounts)} 个账号\n")
|
||||
|
||||
results = []
|
||||
for account in accounts:
|
||||
result = test_account_login(account)
|
||||
results.append(result)
|
||||
time.sleep(1) # 避免请求过快
|
||||
|
||||
# 打印汇总
|
||||
print("\n" + "=" * 60)
|
||||
print(" 📊 测试结果汇总")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = sum(1 for r in results if r.login_success)
|
||||
token_count = sum(1 for r in results if r.has_token)
|
||||
|
||||
print(f"\n总计: {len(results)} 个账号")
|
||||
print(f"✅ 登录成功: {success_count}")
|
||||
print(f"🔑 获取Token: {token_count}")
|
||||
print(f"❌ 登录失败: {len(results) - success_count}")
|
||||
|
||||
if any(not r.login_success for r in results):
|
||||
print("\n失败的账号:")
|
||||
for r in results:
|
||||
if not r.login_success:
|
||||
print(f" • {r.email}: {r.error}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
# 保存更新后的配置(如果获取了新 token)
|
||||
if token_count > 0:
|
||||
print("\n💾 更新配置文件中的 token...")
|
||||
from core.config import save_config
|
||||
save_config(CONFIG)
|
||||
print("✅ 配置已保存")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def test_account_rotation():
|
||||
"""测试账号轮换功能"""
|
||||
from core.auth import choose_account, release_account, account_queue
|
||||
from core.config import CONFIG
|
||||
|
||||
accounts = CONFIG.get("accounts", [])
|
||||
if len(accounts) < 2:
|
||||
print("⚠️ 需要至少 2 个账号来测试轮换")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 🔄 账号轮换测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试选择账号
|
||||
print("\n选择账号 (连续3次):")
|
||||
selected = []
|
||||
for i in range(3):
|
||||
account = choose_account()
|
||||
if account:
|
||||
email = account.get("email", account.get("mobile", "unknown"))
|
||||
selected.append(email)
|
||||
print(f" 第{i+1}次: {email}")
|
||||
else:
|
||||
print(f" 第{i+1}次: 无可用账号")
|
||||
|
||||
# 释放账号
|
||||
print("\n释放账号:")
|
||||
for i, email in enumerate(selected):
|
||||
for acc in accounts:
|
||||
if acc.get("email") == email:
|
||||
release_account(acc)
|
||||
print(f" 已释放: {email}")
|
||||
break
|
||||
|
||||
# 再次选择
|
||||
print("\n释放后再选择:")
|
||||
for i in range(2):
|
||||
account = choose_account()
|
||||
if account:
|
||||
email = account.get("email", account.get("mobile", "unknown"))
|
||||
print(f" 第{i+1}次: {email}")
|
||||
release_account(account)
|
||||
|
||||
print("\n✅ 账号轮换功能正常")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DS2API 账号测试")
|
||||
parser.add_argument("--login", action="store_true", help="测试账号登录")
|
||||
parser.add_argument("--rotation", action="store_true", help="测试账号轮换")
|
||||
parser.add_argument("--all", action="store_true", help="运行所有测试")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all or args.login:
|
||||
test_account_pool()
|
||||
|
||||
if args.all or args.rotation:
|
||||
test_account_rotation()
|
||||
|
||||
if not (args.all or args.login or args.rotation):
|
||||
parser.print_help()
|
||||
print("\n使用 --all 运行所有测试")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,969 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API 全面自动化测试套件
|
||||
|
||||
测试覆盖:
|
||||
- 配置加载和认证
|
||||
- 会话创建
|
||||
- PoW 计算
|
||||
- OpenAI 兼容 API
|
||||
- Claude 兼容 API
|
||||
- 流式和非流式响应
|
||||
- 错误处理
|
||||
- Token 计数
|
||||
|
||||
使用方法:
|
||||
python tests/test_all.py # 运行所有测试
|
||||
python tests/test_all.py --quick # 快速测试(跳过耗时测试)
|
||||
python tests/test_all.py --verbose # 详细输出
|
||||
python tests/test_all.py --endpoint URL # 指定测试端点
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import requests
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# 测试配置
|
||||
DEFAULT_ENDPOINT = "http://localhost:5001"
|
||||
TEST_API_KEY = "test-api-key-001" # 配置中的 API key
|
||||
TEST_TIMEOUT = 120 # 超时时间(秒)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""测试结果"""
|
||||
name: str
|
||||
passed: bool
|
||||
duration: float
|
||||
message: str = ""
|
||||
details: Optional[dict] = None
|
||||
|
||||
|
||||
class TestRunner:
|
||||
"""测试运行器"""
|
||||
|
||||
def __init__(self, endpoint: str, api_key: str, verbose: bool = False):
|
||||
self.endpoint = endpoint.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.verbose = verbose
|
||||
self.results: list[TestResult] = []
|
||||
|
||||
def log(self, message: str, level: str = "INFO"):
|
||||
"""日志输出"""
|
||||
colors = {
|
||||
"INFO": "\033[94m",
|
||||
"SUCCESS": "\033[92m",
|
||||
"WARNING": "\033[93m",
|
||||
"ERROR": "\033[91m",
|
||||
"RESET": "\033[0m"
|
||||
}
|
||||
if self.verbose or level in ("ERROR", "SUCCESS"):
|
||||
print(f"{colors.get(level, '')}{message}{colors['RESET']}")
|
||||
|
||||
def run_test(self, name: str, test_func):
|
||||
"""运行单个测试"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"🧪 测试: {name}")
|
||||
print('='*60)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = test_func()
|
||||
duration = time.time() - start_time
|
||||
|
||||
if result.get("success", False):
|
||||
self.log(f"✅ 通过 ({duration:.2f}s)", "SUCCESS")
|
||||
self.results.append(TestResult(
|
||||
name=name,
|
||||
passed=True,
|
||||
duration=duration,
|
||||
message=result.get("message", ""),
|
||||
details=result.get("details")
|
||||
))
|
||||
else:
|
||||
self.log(f"❌ 失败: {result.get('message', '未知错误')}", "ERROR")
|
||||
self.results.append(TestResult(
|
||||
name=name,
|
||||
passed=False,
|
||||
duration=duration,
|
||||
message=result.get("message", ""),
|
||||
details=result.get("details")
|
||||
))
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.log(f"❌ 异常: {e}", "ERROR")
|
||||
self.results.append(TestResult(
|
||||
name=name,
|
||||
passed=False,
|
||||
duration=duration,
|
||||
message=str(e)
|
||||
))
|
||||
|
||||
def get_headers(self, is_claude: bool = False) -> dict:
|
||||
"""获取请求头"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
}
|
||||
if is_claude:
|
||||
headers["anthropic-version"] = "2024-01-01"
|
||||
return headers
|
||||
|
||||
# =====================================================================
|
||||
# 基础测试
|
||||
# =====================================================================
|
||||
|
||||
def test_health_check(self) -> dict:
|
||||
"""测试服务健康状态"""
|
||||
try:
|
||||
resp = requests.get(f"{self.endpoint}/", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return {"success": True, "message": "服务运行正常"}
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"success": False, "message": "无法连接到服务"}
|
||||
|
||||
# =====================================================================
|
||||
# OpenAI 兼容 API 测试
|
||||
# =====================================================================
|
||||
|
||||
def test_openai_models_list(self) -> dict:
|
||||
"""测试 OpenAI /v1/models 端点"""
|
||||
resp = requests.get(
|
||||
f"{self.endpoint}/v1/models",
|
||||
headers=self.get_headers(),
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
if data.get("object") != "list":
|
||||
return {"success": False, "message": "响应格式错误"}
|
||||
|
||||
models = [m["id"] for m in data.get("data", [])]
|
||||
expected_models = ["deepseek-chat", "deepseek-reasoner", "deepseek-chat-search", "deepseek-reasoner-search"]
|
||||
|
||||
for model in expected_models:
|
||||
if model not in models:
|
||||
return {"success": False, "message": f"缺少模型: {model}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"返回 {len(models)} 个模型",
|
||||
"details": {"models": models}
|
||||
}
|
||||
|
||||
def test_openai_chat_non_stream(self) -> dict:
|
||||
"""测试 OpenAI 非流式对话"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "请用一句话回答:1+1等于多少?"}
|
||||
],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}", "details": {"response": resp.text}}
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return {"success": False, "message": data["error"]}
|
||||
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
if not content:
|
||||
return {"success": False, "message": "响应内容为空"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"收到 {len(content)} 字符响应",
|
||||
"details": {
|
||||
"content_preview": content[:100] + "..." if len(content) > 100 else content,
|
||||
"usage": data.get("usage", {})
|
||||
}
|
||||
}
|
||||
|
||||
def test_openai_chat_stream(self) -> dict:
|
||||
"""测试 OpenAI 流式对话"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "说'你好'"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
chunks = []
|
||||
content = ""
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
data_str = line_str[6:]
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
if "content" in delta:
|
||||
content += delta["content"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not chunks:
|
||||
return {"success": False, "message": "未收到任何流式数据块"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"收到 {len(chunks)} 个数据块,内容: {content[:50]}",
|
||||
"details": {"chunk_count": len(chunks), "content": content}
|
||||
}
|
||||
|
||||
def test_openai_reasoner_stream(self) -> dict:
|
||||
"""测试 OpenAI Reasoner 模式(思考链)"""
|
||||
payload = {
|
||||
"model": "deepseek-reasoner",
|
||||
"messages": [
|
||||
{"role": "user", "content": "1加2等于多少?"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
content = ""
|
||||
reasoning = ""
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
data_str = line_str[6:]
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
if "content" in delta:
|
||||
content += delta["content"]
|
||||
if "reasoning_content" in delta:
|
||||
reasoning += delta["reasoning_content"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"思考: {len(reasoning)}字, 回答: {len(content)}字",
|
||||
"details": {
|
||||
"reasoning_preview": reasoning[:100] + "..." if len(reasoning) > 100 else reasoning,
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
|
||||
def test_openai_invalid_model(self) -> dict:
|
||||
"""测试无效模型错误处理"""
|
||||
payload = {
|
||||
"model": "invalid-model-name",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
# 应该返回 503 或 400
|
||||
if resp.status_code in (503, 400):
|
||||
return {"success": True, "message": f"正确返回错误状态码 {resp.status_code}"}
|
||||
|
||||
return {"success": False, "message": f"期望 503/400,实际: {resp.status_code}"}
|
||||
|
||||
def test_openai_missing_auth(self) -> dict:
|
||||
"""测试缺少认证的错误处理"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers={"Content-Type": "application/json"}, # 无 Authorization
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
return {"success": True, "message": "正确返回 401 未认证"}
|
||||
|
||||
return {"success": False, "message": f"期望 401,实际: {resp.status_code}"}
|
||||
|
||||
# =====================================================================
|
||||
# Claude 兼容 API 测试
|
||||
# =====================================================================
|
||||
|
||||
def test_claude_models_list(self) -> dict:
|
||||
"""测试 Claude /anthropic/v1/models 端点"""
|
||||
resp = requests.get(
|
||||
f"{self.endpoint}/anthropic/v1/models",
|
||||
headers=self.get_headers(is_claude=True),
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
models = [m["id"] for m in data.get("data", [])]
|
||||
|
||||
if not models:
|
||||
return {"success": False, "message": "模型列表为空"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"返回 {len(models)} 个 Claude 模型",
|
||||
"details": {"models": models}
|
||||
}
|
||||
|
||||
def test_claude_messages_non_stream(self) -> dict:
|
||||
"""测试 Claude 非流式消息"""
|
||||
payload = {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 100,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Say 'Hello' in Chinese"}
|
||||
],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/anthropic/v1/messages",
|
||||
headers=self.get_headers(is_claude=True),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}", "details": {"response": resp.text}}
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return {"success": False, "message": str(data["error"])}
|
||||
|
||||
content_blocks = data.get("content", [])
|
||||
text_content = ""
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
text_content += block.get("text", "")
|
||||
|
||||
if not text_content:
|
||||
return {"success": False, "message": "响应内容为空"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"收到 Claude 格式响应: {len(text_content)} 字符",
|
||||
"details": {
|
||||
"content": text_content[:100],
|
||||
"stop_reason": data.get("stop_reason"),
|
||||
"usage": data.get("usage", {})
|
||||
}
|
||||
}
|
||||
|
||||
def test_claude_messages_stream(self) -> dict:
|
||||
"""测试 Claude 流式消息"""
|
||||
payload = {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 50,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Reply with just 'OK'"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/anthropic/v1/messages",
|
||||
headers=self.get_headers(is_claude=True),
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
events = []
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
try:
|
||||
event = json.loads(line_str[6:])
|
||||
events.append(event)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
event_types = [e.get("type") for e in events]
|
||||
|
||||
# 检查必要的事件类型
|
||||
required_types = ["message_start", "message_stop"]
|
||||
for rt in required_types:
|
||||
if rt not in event_types:
|
||||
return {"success": False, "message": f"缺少事件类型: {rt}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"收到 {len(events)} 个 Claude 流事件",
|
||||
"details": {"event_types": event_types}
|
||||
}
|
||||
|
||||
def test_claude_count_tokens(self) -> dict:
|
||||
"""测试 Claude token 计数"""
|
||||
payload = {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, how are you today?"}
|
||||
]
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/anthropic/v1/messages/count_tokens",
|
||||
headers=self.get_headers(is_claude=True),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
input_tokens = data.get("input_tokens", 0)
|
||||
|
||||
if input_tokens <= 0:
|
||||
return {"success": False, "message": f"token 计数无效: {input_tokens}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Token 计数: {input_tokens}",
|
||||
"details": data
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# 高级功能测试
|
||||
# =====================================================================
|
||||
|
||||
def test_multi_turn_conversation(self) -> dict:
|
||||
"""测试多轮对话"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一个数学助手"},
|
||||
{"role": "user", "content": "我有3个苹果"},
|
||||
{"role": "assistant", "content": "好的,你有3个苹果。"},
|
||||
{"role": "user", "content": "我又买了2个,现在有多少?"}
|
||||
],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# 检查是否包含"5"
|
||||
if "5" in content:
|
||||
return {"success": True, "message": f"AI 正确理解上下文", "details": {"content": content[:100]}}
|
||||
|
||||
return {
|
||||
"success": True, # 即使没有5也算通过,因为测试的是多轮对话功能
|
||||
"message": f"多轮对话功能正常",
|
||||
"details": {"content": content[:100]}
|
||||
}
|
||||
|
||||
def test_long_input(self) -> dict:
|
||||
"""测试长输入处理"""
|
||||
# 生成约 1000 字的输入
|
||||
long_text = "这是一段测试文本。" * 100
|
||||
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": f"请总结以下内容的主题:{long_text}"}
|
||||
],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return {"success": False, "message": str(data.get("error"))}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功处理 {len(long_text)} 字符输入",
|
||||
"details": {"input_length": len(long_text)}
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# 管理 API 测试
|
||||
# =====================================================================
|
||||
|
||||
def test_admin_config(self) -> dict:
|
||||
"""测试管理配置 API"""
|
||||
resp = requests.get(
|
||||
f"{self.endpoint}/admin/config",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# 验证返回结构
|
||||
if "accounts" not in data:
|
||||
return {"success": False, "message": "响应缺少 accounts 字段"}
|
||||
|
||||
# 验证 token_preview 字段存在
|
||||
accounts = data.get("accounts", [])
|
||||
if accounts:
|
||||
first_acc = accounts[0]
|
||||
if "token_preview" not in first_acc:
|
||||
return {"success": False, "message": "响应缺少 token_preview 字段"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"获取配置成功,{len(accounts)} 个账号",
|
||||
"details": {"account_count": len(accounts)}
|
||||
}
|
||||
|
||||
def test_admin_account_test(self) -> dict:
|
||||
"""测试单账号 API 测试端点"""
|
||||
# 先获取配置以获取账号
|
||||
config_resp = requests.get(f"{self.endpoint}/admin/config", timeout=10)
|
||||
if config_resp.status_code != 200:
|
||||
return {"success": False, "message": "获取配置失败"}
|
||||
|
||||
accounts = config_resp.json().get("accounts", [])
|
||||
if not accounts:
|
||||
return {"success": False, "message": "没有可测试的账号"}
|
||||
|
||||
# 测试第一个账号
|
||||
first_acc = accounts[0]
|
||||
identifier = first_acc.get("email") or first_acc.get("mobile")
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/admin/accounts/test",
|
||||
json={"identifier": identifier},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# 验证返回结构
|
||||
required_fields = ["account", "success", "response_time", "message"]
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return {"success": False, "message": f"响应缺少 {field} 字段"}
|
||||
|
||||
if not data["success"]:
|
||||
return {"success": False, "message": f"账号测试失败: {data['message']}"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"账号 {identifier} 测试成功 ({data['response_time']}ms)",
|
||||
"details": {"response_time": data["response_time"]}
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# 工具调用测试
|
||||
# =====================================================================
|
||||
|
||||
def test_openai_tool_calling(self) -> dict:
|
||||
"""测试 OpenAI 工具调用"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather in Beijing? Use the get_weather tool."}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get current weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City name"}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}", "details": {"response": resp.text}}
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return {"success": False, "message": data["error"]}
|
||||
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
finish_reason = data.get("choices", [{}])[0].get("finish_reason", "")
|
||||
content = message.get("content", "")
|
||||
|
||||
# AI 可能调用工具,也可能直接回复
|
||||
if tool_calls:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"检测到 {len(tool_calls)} 个工具调用, finish_reason={finish_reason}",
|
||||
"details": {"tool_calls": tool_calls}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"AI 直接回复而非调用工具: {content[:50]}...",
|
||||
"details": {"content": content[:100]}
|
||||
}
|
||||
|
||||
def test_openai_tool_calling_stream(self) -> dict:
|
||||
"""测试 OpenAI 流式工具调用"""
|
||||
payload = {
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Use get_time tool to check current time in Tokyo."}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_time",
|
||||
"description": "Get current time for a timezone",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timezone": {"type": "string"}
|
||||
},
|
||||
"required": ["timezone"]
|
||||
}
|
||||
}
|
||||
}],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
chunks = []
|
||||
tool_calls_found = False
|
||||
finish_reason = None
|
||||
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
data_str = line_str[6:]
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
chunks.append(chunk)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
if "tool_calls" in delta:
|
||||
tool_calls_found = True
|
||||
fr = chunk.get("choices", [{}])[0].get("finish_reason")
|
||||
if fr:
|
||||
finish_reason = fr
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"收到 {len(chunks)} 个数据块, 工具调用: {tool_calls_found}, finish: {finish_reason}",
|
||||
"details": {"chunk_count": len(chunks), "tool_calls_found": tool_calls_found}
|
||||
}
|
||||
|
||||
def test_claude_tool_calling(self) -> dict:
|
||||
"""测试 Claude 工具调用"""
|
||||
payload = {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": 200,
|
||||
"messages": [
|
||||
{"role": "user", "content": "Use the calculator tool to compute 15 * 23"}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "calculator",
|
||||
"description": "Perform arithmetic calculations",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {"type": "string", "description": "Math expression"}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
}],
|
||||
"stream": False
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/anthropic/v1/messages",
|
||||
headers=self.get_headers(is_claude=True),
|
||||
json=payload,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}", "details": {"response": resp.text}}
|
||||
|
||||
data = resp.json()
|
||||
if "error" in data:
|
||||
return {"success": False, "message": str(data["error"])}
|
||||
|
||||
content_blocks = data.get("content", [])
|
||||
stop_reason = data.get("stop_reason", "")
|
||||
|
||||
tool_use_blocks = [b for b in content_blocks if b.get("type") == "tool_use"]
|
||||
text_blocks = [b for b in content_blocks if b.get("type") == "text"]
|
||||
|
||||
if tool_use_blocks:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"检测到 {len(tool_use_blocks)} 个工具调用, stop_reason={stop_reason}",
|
||||
"details": {"tool_use": tool_use_blocks}
|
||||
}
|
||||
else:
|
||||
text_content = "".join(b.get("text", "") for b in text_blocks)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"AI 直接回复: {text_content[:50]}...",
|
||||
"details": {"content": text_content[:100]}
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# 搜索模式测试
|
||||
# =====================================================================
|
||||
|
||||
def test_openai_search_mode(self) -> dict:
|
||||
"""测试 OpenAI 搜索模式"""
|
||||
payload = {
|
||||
"model": "deepseek-chat-search",
|
||||
"messages": [
|
||||
{"role": "user", "content": "今天的新闻有哪些?"}
|
||||
],
|
||||
"stream": True
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{self.endpoint}/v1/chat/completions",
|
||||
headers=self.get_headers(),
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=TEST_TIMEOUT
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"success": False, "message": f"状态码: {resp.status_code}"}
|
||||
|
||||
content = ""
|
||||
for line in resp.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode("utf-8")
|
||||
if line_str.startswith("data: "):
|
||||
data_str = line_str[6:]
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data_str)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
if "content" in delta:
|
||||
content += delta["content"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not content:
|
||||
return {"success": False, "message": "搜索模式无响应内容"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"搜索模式正常,收到 {len(content)} 字符",
|
||||
"details": {"content_preview": content[:100]}
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# 运行测试
|
||||
# =====================================================================
|
||||
|
||||
def run_all_tests(self, quick: bool = False):
|
||||
"""运行所有测试"""
|
||||
print("\n" + "="*70)
|
||||
print(" 🚀 DS2API 全面自动化测试")
|
||||
print("="*70)
|
||||
print(f"端点: {self.endpoint}")
|
||||
print(f"API Key: {self.api_key[:10]}...")
|
||||
print(f"模式: {'快速' if quick else '完整'}")
|
||||
|
||||
# 基础测试
|
||||
self.run_test("服务健康检查", self.test_health_check)
|
||||
|
||||
if not self.results[-1].passed:
|
||||
print("\n⚠️ 服务未运行,跳过其他测试")
|
||||
return
|
||||
|
||||
# OpenAI API 测试
|
||||
self.run_test("OpenAI 模型列表", self.test_openai_models_list)
|
||||
self.run_test("OpenAI 非流式对话", self.test_openai_chat_non_stream)
|
||||
self.run_test("OpenAI 流式对话", self.test_openai_chat_stream)
|
||||
self.run_test("OpenAI 无效模型处理", self.test_openai_invalid_model)
|
||||
self.run_test("OpenAI 缺少认证处理", self.test_openai_missing_auth)
|
||||
|
||||
if not quick:
|
||||
self.run_test("OpenAI Reasoner 模式", self.test_openai_reasoner_stream)
|
||||
|
||||
# Claude API 测试
|
||||
self.run_test("Claude 模型列表", self.test_claude_models_list)
|
||||
self.run_test("Claude 非流式消息", self.test_claude_messages_non_stream)
|
||||
self.run_test("Claude 流式消息", self.test_claude_messages_stream)
|
||||
self.run_test("Claude Token 计数", self.test_claude_count_tokens)
|
||||
|
||||
# 高级功能测试
|
||||
if not quick:
|
||||
self.run_test("多轮对话", self.test_multi_turn_conversation)
|
||||
self.run_test("长输入处理", self.test_long_input)
|
||||
self.run_test("OpenAI 搜索模式", self.test_openai_search_mode)
|
||||
|
||||
# 工具调用测试
|
||||
if not quick:
|
||||
self.run_test("OpenAI 工具调用", self.test_openai_tool_calling)
|
||||
self.run_test("OpenAI 流式工具调用", self.test_openai_tool_calling_stream)
|
||||
self.run_test("Claude 工具调用", self.test_claude_tool_calling)
|
||||
|
||||
# 管理 API 测试
|
||||
self.run_test("管理配置 API", self.test_admin_config)
|
||||
self.run_test("账号测试 API", self.test_admin_account_test)
|
||||
|
||||
# 输出测试报告
|
||||
self.print_report()
|
||||
|
||||
def print_report(self):
|
||||
"""打印测试报告"""
|
||||
print("\n" + "="*70)
|
||||
print(" 📊 测试报告")
|
||||
print("="*70)
|
||||
|
||||
passed = sum(1 for r in self.results if r.passed)
|
||||
failed = len(self.results) - passed
|
||||
total_time = sum(r.duration for r in self.results)
|
||||
|
||||
print(f"\n总计: {len(self.results)} 个测试")
|
||||
print(f"✅ 通过: {passed}")
|
||||
print(f"❌ 失败: {failed}")
|
||||
print(f"⏱️ 耗时: {total_time:.2f}s")
|
||||
print(f"📈 通过率: {passed/len(self.results)*100:.1f}%")
|
||||
|
||||
if failed > 0:
|
||||
print("\n❌ 失败的测试:")
|
||||
for r in self.results:
|
||||
if not r.passed:
|
||||
print(f" • {r.name}: {r.message}")
|
||||
|
||||
print("\n" + "="*70)
|
||||
|
||||
# 返回退出码
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DS2API 自动化测试")
|
||||
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT, help="API 端点")
|
||||
parser.add_argument("--api-key", default=TEST_API_KEY, help="API Key")
|
||||
parser.add_argument("--quick", action="store_true", help="快速测试模式")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="详细输出")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
runner = TestRunner(
|
||||
endpoint=args.endpoint,
|
||||
api_key=args.api_key,
|
||||
verbose=args.verbose
|
||||
)
|
||||
|
||||
exit_code = runner.run_all_tests(quick=args.quick)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,565 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API 单元测试
|
||||
|
||||
测试核心模块的功能,不依赖网络请求
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
"""配置模块测试"""
|
||||
|
||||
def test_config_loading(self):
|
||||
"""测试配置加载"""
|
||||
from core.config import load_config, CONFIG
|
||||
|
||||
# 测试加载函数不会抛出异常
|
||||
config = load_config()
|
||||
self.assertIsInstance(config, dict)
|
||||
|
||||
def test_config_paths(self):
|
||||
"""测试配置路径"""
|
||||
from core.config import WASM_PATH, CONFIG_PATH
|
||||
|
||||
# 路径应该是字符串
|
||||
self.assertIsInstance(WASM_PATH, str)
|
||||
self.assertIsInstance(CONFIG_PATH, str)
|
||||
|
||||
|
||||
class TestMessages(unittest.TestCase):
|
||||
"""消息处理模块测试"""
|
||||
|
||||
def test_messages_prepare_simple(self):
|
||||
"""测试简单消息处理"""
|
||||
from core.messages import messages_prepare
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
result = messages_prepare(messages)
|
||||
self.assertIn("Hello", result)
|
||||
|
||||
def test_messages_prepare_multi_turn(self):
|
||||
"""测试多轮对话消息处理"""
|
||||
from core.messages import messages_prepare
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helper."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello!"},
|
||||
{"role": "user", "content": "How are you?"}
|
||||
]
|
||||
result = messages_prepare(messages)
|
||||
|
||||
# 检查助手消息标签
|
||||
self.assertIn("<|Assistant|>", result)
|
||||
self.assertIn("<|end▁of▁sentence|>", result)
|
||||
# 检查用户消息标签
|
||||
self.assertIn("<|User|>", result)
|
||||
|
||||
def test_messages_prepare_array_content(self):
|
||||
"""测试数组格式内容处理"""
|
||||
from core.messages import messages_prepare
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "First part"},
|
||||
{"type": "text", "text": "Second part"},
|
||||
{"type": "image", "url": "http://example.com/image.png"}
|
||||
]
|
||||
}
|
||||
]
|
||||
result = messages_prepare(messages)
|
||||
|
||||
self.assertIn("First part", result)
|
||||
self.assertIn("Second part", result)
|
||||
|
||||
def test_markdown_image_removal(self):
|
||||
"""测试 markdown 图片格式移除"""
|
||||
from core.messages import messages_prepare
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Check this  image"}
|
||||
]
|
||||
result = messages_prepare(messages)
|
||||
|
||||
# 图片格式应该被改为链接格式
|
||||
self.assertNotIn("![alt]", result)
|
||||
self.assertIn("[alt]", result)
|
||||
|
||||
def test_merge_consecutive_messages(self):
|
||||
"""测试连续相同角色消息合并"""
|
||||
from core.messages import messages_prepare
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Part 1"},
|
||||
{"role": "user", "content": "Part 2"},
|
||||
{"role": "user", "content": "Part 3"}
|
||||
]
|
||||
result = messages_prepare(messages)
|
||||
|
||||
self.assertIn("Part 1", result)
|
||||
self.assertIn("Part 2", result)
|
||||
self.assertIn("Part 3", result)
|
||||
|
||||
def test_convert_claude_to_deepseek(self):
|
||||
"""测试 Claude 到 DeepSeek 格式转换"""
|
||||
from core.messages import convert_claude_to_deepseek
|
||||
|
||||
claude_request = {
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"system": "You are helpful.",
|
||||
"temperature": 0.7,
|
||||
"stream": True
|
||||
}
|
||||
|
||||
result = convert_claude_to_deepseek(claude_request)
|
||||
|
||||
# 检查模型映射
|
||||
self.assertIn("deepseek", result.get("model", "").lower())
|
||||
|
||||
# 检查 system 消息插入
|
||||
self.assertEqual(result["messages"][0]["role"], "system")
|
||||
self.assertEqual(result["messages"][0]["content"], "You are helpful.")
|
||||
|
||||
# 检查其他参数
|
||||
self.assertEqual(result.get("temperature"), 0.7)
|
||||
self.assertEqual(result.get("stream"), True)
|
||||
|
||||
|
||||
class TestPow(unittest.TestCase):
|
||||
"""PoW 模块测试"""
|
||||
|
||||
def test_wasm_caching(self):
|
||||
"""测试 WASM 缓存功能"""
|
||||
from core.pow import _get_cached_wasm_module, _wasm_module, _wasm_engine
|
||||
from core.config import WASM_PATH
|
||||
|
||||
# 首次调用
|
||||
engine1, module1 = _get_cached_wasm_module(WASM_PATH)
|
||||
self.assertIsNotNone(engine1)
|
||||
self.assertIsNotNone(module1)
|
||||
|
||||
# 再次调用应该返回相同的实例
|
||||
engine2, module2 = _get_cached_wasm_module(WASM_PATH)
|
||||
self.assertIs(engine1, engine2)
|
||||
self.assertIs(module1, module2)
|
||||
|
||||
def test_get_account_identifier(self):
|
||||
"""测试账号标识获取"""
|
||||
from core.utils import get_account_identifier
|
||||
|
||||
# 测试邮箱
|
||||
account1 = {"email": "test@example.com"}
|
||||
self.assertEqual(get_account_identifier(account1), "test@example.com")
|
||||
|
||||
# 测试手机号
|
||||
account2 = {"mobile": "13800138000"}
|
||||
self.assertEqual(get_account_identifier(account2), "13800138000")
|
||||
|
||||
# 邮箱优先
|
||||
account3 = {"email": "test@example.com", "mobile": "13800138000"}
|
||||
self.assertEqual(get_account_identifier(account3), "test@example.com")
|
||||
|
||||
|
||||
class TestSessionManager(unittest.TestCase):
|
||||
"""会话管理器模块测试"""
|
||||
|
||||
def test_get_model_config(self):
|
||||
"""测试模型配置获取"""
|
||||
from core.session_manager import get_model_config
|
||||
|
||||
# deepseek-chat
|
||||
thinking, search = get_model_config("deepseek-chat")
|
||||
self.assertEqual(thinking, False)
|
||||
self.assertEqual(search, False)
|
||||
|
||||
# deepseek-reasoner
|
||||
thinking, search = get_model_config("deepseek-reasoner")
|
||||
self.assertEqual(thinking, True)
|
||||
self.assertEqual(search, False)
|
||||
|
||||
# deepseek-chat-search
|
||||
thinking, search = get_model_config("deepseek-chat-search")
|
||||
self.assertEqual(thinking, False)
|
||||
self.assertEqual(search, True)
|
||||
|
||||
# deepseek-reasoner-search
|
||||
thinking, search = get_model_config("deepseek-reasoner-search")
|
||||
self.assertEqual(thinking, True)
|
||||
self.assertEqual(search, True)
|
||||
|
||||
# 大小写不敏感
|
||||
thinking, search = get_model_config("DeepSeek-CHAT")
|
||||
self.assertEqual(thinking, False)
|
||||
self.assertEqual(search, False)
|
||||
|
||||
# 无效模型
|
||||
thinking, search = get_model_config("invalid-model")
|
||||
self.assertIsNone(thinking)
|
||||
self.assertIsNone(search)
|
||||
|
||||
|
||||
class TestAuth(unittest.TestCase):
|
||||
"""认证模块测试"""
|
||||
|
||||
def test_auth_key_check(self):
|
||||
"""测试 API Key 检查"""
|
||||
from core.config import CONFIG
|
||||
|
||||
# 检查配置中是否有 keys
|
||||
keys = CONFIG.get("keys", [])
|
||||
self.assertIsInstance(keys, list)
|
||||
|
||||
|
||||
class TestRegexPatterns(unittest.TestCase):
|
||||
"""正则表达式测试"""
|
||||
|
||||
def test_markdown_image_pattern(self):
|
||||
"""测试 markdown 图片正则"""
|
||||
from core.messages import _MARKDOWN_IMAGE_PATTERN
|
||||
|
||||
text = "Check  here"
|
||||
match = _MARKDOWN_IMAGE_PATTERN.search(text)
|
||||
|
||||
self.assertIsNotNone(match)
|
||||
self.assertEqual(match.group(1), "alt text")
|
||||
self.assertEqual(match.group(2), "http://example.com/image.png")
|
||||
|
||||
|
||||
class TestStreamParsing(unittest.TestCase):
|
||||
"""流式响应解析测试"""
|
||||
|
||||
def test_parse_simple_string_content(self):
|
||||
"""测试简单字符串内容解析"""
|
||||
# 模拟 DeepSeek V3 的简单字符串格式
|
||||
chunk = {"v": "你好"}
|
||||
|
||||
v_value = chunk.get("v")
|
||||
self.assertIsInstance(v_value, str)
|
||||
self.assertEqual(v_value, "你好")
|
||||
|
||||
def test_parse_nested_list_content(self):
|
||||
"""测试嵌套列表内容解析 (DeepSeek V3 格式)"""
|
||||
# 模拟 DeepSeek V3 的嵌套列表格式
|
||||
chunk = {
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": [
|
||||
{"id": 1, "type": "RESPONSE", "content": "我是DeepSeek", "references": [], "stage_id": 1}
|
||||
]
|
||||
}
|
||||
|
||||
v_value = chunk.get("v")
|
||||
self.assertIsInstance(v_value, list)
|
||||
self.assertEqual(len(v_value), 1)
|
||||
|
||||
inner = v_value[0]
|
||||
self.assertEqual(inner.get("type"), "RESPONSE")
|
||||
self.assertEqual(inner.get("content"), "我是DeepSeek")
|
||||
|
||||
def test_parse_thinking_content(self):
|
||||
"""测试 thinking 内容解析"""
|
||||
# 模拟带有 THINK 类型的内容 (DeepSeek 使用 THINK 而不是 THINKING)
|
||||
chunk = {
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": [
|
||||
{"id": 1, "type": "THINK", "content": "让我思考一下...", "references": [], "stage_id": 1}
|
||||
]
|
||||
}
|
||||
|
||||
v_value = chunk.get("v")
|
||||
inner = v_value[0]
|
||||
|
||||
inner_type = inner.get("type", "").upper()
|
||||
self.assertEqual(inner_type, "THINK")
|
||||
self.assertEqual(inner.get("content"), "让我思考一下...")
|
||||
|
||||
def test_parse_finished_status(self):
|
||||
"""测试 FINISHED 状态解析"""
|
||||
chunk = {"p": "response/status", "o": "SET", "v": "FINISHED"}
|
||||
|
||||
v_value = chunk.get("v")
|
||||
self.assertEqual(v_value, "FINISHED")
|
||||
|
||||
def test_parse_batch_status(self):
|
||||
"""测试批量状态解析"""
|
||||
chunk = {
|
||||
"p": "response",
|
||||
"o": "BATCH",
|
||||
"v": [
|
||||
{"p": "accumulated_token_usage", "v": 54},
|
||||
{"p": "quasi_status", "v": "FINISHED"}
|
||||
]
|
||||
}
|
||||
|
||||
v_value = chunk.get("v")
|
||||
self.assertIsInstance(v_value, list)
|
||||
|
||||
# 检查是否包含 FINISHED 状态
|
||||
has_finished = any(
|
||||
item.get("p") == "quasi_status" and item.get("v") == "FINISHED"
|
||||
for item in v_value if isinstance(item, dict)
|
||||
)
|
||||
self.assertTrue(has_finished)
|
||||
|
||||
def test_extract_content_from_nested_response(self):
|
||||
"""测试从嵌套响应中提取内容"""
|
||||
# 模拟完整的嵌套列表格式
|
||||
items = [
|
||||
{"p": "fragments", "o": "APPEND", "v": [
|
||||
{"id": 1, "type": "RESPONSE", "content": "Hello", "references": []}
|
||||
]},
|
||||
{"p": "search_status", "v": "searching"}, # 应该被跳过
|
||||
]
|
||||
|
||||
extracted = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
item_p = item.get("p", "")
|
||||
item_v = item.get("v")
|
||||
|
||||
# 跳过搜索状态
|
||||
if "search_status" in item_p:
|
||||
continue
|
||||
|
||||
if isinstance(item_v, list):
|
||||
for inner in item_v:
|
||||
if isinstance(inner, dict):
|
||||
content = inner.get("content", "")
|
||||
if content:
|
||||
inner_type = inner.get("type", "").upper()
|
||||
extracted.append((content, inner_type))
|
||||
|
||||
self.assertEqual(len(extracted), 1)
|
||||
self.assertEqual(extracted[0], ("Hello", "RESPONSE"))
|
||||
|
||||
def test_thinking_vs_text_classification(self):
|
||||
"""测试 thinking 和 text 类型分类"""
|
||||
# 测试不同路径的类型分类
|
||||
test_cases = [
|
||||
("response/thinking_content", "thinking"),
|
||||
("response/content", "text"),
|
||||
("response/fragments", "text"),
|
||||
("", "text"), # 默认类型
|
||||
]
|
||||
|
||||
for chunk_path, expected_type in test_cases:
|
||||
if chunk_path == "response/thinking_content":
|
||||
ptype = "thinking"
|
||||
elif chunk_path == "response/content" or "response/fragments" in chunk_path:
|
||||
ptype = "text"
|
||||
else:
|
||||
ptype = "text"
|
||||
|
||||
self.assertEqual(ptype, expected_type, f"Path '{chunk_path}' should be '{expected_type}'")
|
||||
|
||||
def test_handle_non_dict_items(self):
|
||||
"""测试处理非字典类型的列表项"""
|
||||
items = [
|
||||
"plain string",
|
||||
123,
|
||||
None,
|
||||
{"p": "content", "v": "valid"},
|
||||
]
|
||||
|
||||
valid_items = [item for item in items if isinstance(item, dict)]
|
||||
self.assertEqual(len(valid_items), 1)
|
||||
self.assertEqual(valid_items[0].get("v"), "valid")
|
||||
|
||||
def test_empty_content_handling(self):
|
||||
"""测试空内容处理"""
|
||||
chunk = {"v": ""}
|
||||
|
||||
content = chunk.get("v", "")
|
||||
# 空内容不应该被添加
|
||||
self.assertFalse(bool(content))
|
||||
|
||||
def test_response_started_flag(self):
|
||||
"""测试 response_started 标志逻辑 - 只有 RESPONSE 类型才触发"""
|
||||
response_started = False
|
||||
thinking_enabled = True
|
||||
|
||||
# 模拟处理流程 - 修复后的逻辑
|
||||
chunks = [
|
||||
{"v": "思考中..."}, # thinking (before response)
|
||||
{"p": "response/fragments", "v": [{"type": "THINK", "content": "思考"}]}, # THINK 不触发 response_started
|
||||
{"v": "继续思考..."}, # 仍然是 thinking
|
||||
{"p": "response/fragments", "v": [{"type": "RESPONSE", "content": "回复"}]}, # RESPONSE 触发
|
||||
{"v": "正式回复"}, # text (after response started)
|
||||
]
|
||||
|
||||
results = []
|
||||
for chunk in chunks:
|
||||
chunk_path = chunk.get("p", "")
|
||||
v_value = chunk.get("v")
|
||||
|
||||
# 只有当 fragments 包含 RESPONSE 类型时才设置 response_started
|
||||
if "response/fragments" in chunk_path and isinstance(v_value, list):
|
||||
for frag in v_value:
|
||||
if isinstance(frag, dict) and frag.get("type", "").upper() == "RESPONSE":
|
||||
response_started = True
|
||||
break
|
||||
|
||||
if not chunk_path:
|
||||
if thinking_enabled and not response_started:
|
||||
ptype = "thinking"
|
||||
else:
|
||||
ptype = "text"
|
||||
else:
|
||||
ptype = "text"
|
||||
|
||||
results.append((ptype, response_started))
|
||||
|
||||
self.assertEqual(results[0], ("thinking", False)) # 第一个是 thinking
|
||||
self.assertEqual(results[1], ("text", False)) # THINK fragment 不触发 response_started
|
||||
self.assertEqual(results[2], ("thinking", False)) # THINK 之后仍是 thinking
|
||||
self.assertEqual(results[3], ("text", True)) # RESPONSE fragment 触发
|
||||
self.assertEqual(results[4], ("text", True)) # 之后是 text
|
||||
|
||||
def test_think_vs_response_fragment_types(self):
|
||||
"""测试 THINK 和 RESPONSE fragment 类型的区分"""
|
||||
# 模拟 DeepSeek 的 fragments 数据
|
||||
think_fragment = {"p": "response/fragments", "v": [{"id": 1, "type": "THINK", "content": "嗯"}]}
|
||||
response_fragment = {"p": "response/fragments", "v": [{"id": 2, "type": "RESPONSE", "content": "你好"}]}
|
||||
|
||||
def check_response_started(chunk):
|
||||
"""检查是否应该设置 response_started"""
|
||||
chunk_path = chunk.get("p", "")
|
||||
v_value = chunk.get("v")
|
||||
if "response/fragments" in chunk_path and isinstance(v_value, list):
|
||||
for frag in v_value:
|
||||
if isinstance(frag, dict) and frag.get("type", "").upper() == "RESPONSE":
|
||||
return True
|
||||
return False
|
||||
|
||||
self.assertFalse(check_response_started(think_fragment)) # THINK 不触发
|
||||
self.assertTrue(check_response_started(response_fragment)) # RESPONSE 触发
|
||||
|
||||
|
||||
class TestToolCallParsing(unittest.TestCase):
|
||||
"""工具调用解析测试"""
|
||||
|
||||
def test_parse_tool_calls_simple(self):
|
||||
"""测试简单工具调用解析"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
response_text = '{"tool_calls": [{"name": "get_weather", "input": {"location": "Beijing"}}]}'
|
||||
tools = [{"name": "get_weather"}]
|
||||
|
||||
result = parse_tool_calls(response_text, tools)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["name"], "get_weather")
|
||||
self.assertEqual(result[0]["input"]["location"], "Beijing")
|
||||
|
||||
def test_parse_tool_calls_multiple(self):
|
||||
"""测试多工具调用解析"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
response_text = '''{"tool_calls": [
|
||||
{"name": "get_weather", "input": {"location": "Beijing"}},
|
||||
{"name": "get_time", "input": {"timezone": "Asia/Shanghai"}}
|
||||
]}'''
|
||||
tools = [{"name": "get_weather"}, {"name": "get_time"}]
|
||||
|
||||
result = parse_tool_calls(response_text, tools)
|
||||
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]["name"], "get_weather")
|
||||
self.assertEqual(result[1]["name"], "get_time")
|
||||
|
||||
def test_parse_tool_calls_no_match(self):
|
||||
"""测试无工具调用时返回空列表"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
response_text = "这是一个普通的回复,没有工具调用。"
|
||||
tools = [{"name": "get_weather"}]
|
||||
|
||||
result = parse_tool_calls(response_text, tools)
|
||||
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_parse_tool_calls_with_surrounding_text(self):
|
||||
"""测试带有周围文本的工具调用"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
response_text = '''好的,我来帮你查询天气。
|
||||
{"tool_calls": [{"name": "get_weather", "input": {"location": "Shanghai"}}]}'''
|
||||
tools = [{"name": "get_weather"}]
|
||||
|
||||
result = parse_tool_calls(response_text, tools)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]["name"], "get_weather")
|
||||
|
||||
def test_parse_tool_calls_empty_input(self):
|
||||
"""测试空输入"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
result = parse_tool_calls("", [])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
result = parse_tool_calls("some text", [])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_parse_tool_calls_invalid_json(self):
|
||||
"""测试无效 JSON"""
|
||||
from core.sse_parser import parse_tool_calls
|
||||
|
||||
response_text = '{"tool_calls": [{"name": "get_weather", invalid json here}'
|
||||
tools = [{"name": "get_weather"}]
|
||||
|
||||
result = parse_tool_calls(response_text, tools)
|
||||
|
||||
# 应该返回空列表而不是抛出异常
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestTokenEstimation(unittest.TestCase):
|
||||
"""Token 估算测试"""
|
||||
|
||||
def test_estimate_tokens_string(self):
|
||||
"""测试字符串 token 估算"""
|
||||
from core.utils import estimate_tokens
|
||||
|
||||
# 8个字符应该约等于2个token
|
||||
result = estimate_tokens("12345678")
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
# 空字符串应该返回1
|
||||
result = estimate_tokens("")
|
||||
self.assertEqual(result, 1)
|
||||
|
||||
def test_estimate_tokens_list(self):
|
||||
"""测试列表 token 估算"""
|
||||
from core.utils import estimate_tokens
|
||||
|
||||
content = [
|
||||
{"text": "Hello"},
|
||||
{"text": "World"}
|
||||
]
|
||||
result = estimate_tokens(content)
|
||||
self.assertGreater(result, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 设置环境变量避免配置警告
|
||||
os.environ.setdefault("DS2API_CONFIG_PATH",
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.json"))
|
||||
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1 +0,0 @@
|
||||
# DS2API Tools
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DS2API 配置生成器
|
||||
|
||||
交互式工具,用于批量配置账号和 API Keys。
|
||||
支持导出为 JSON 和 Base64 格式,方便 Vercel 部署配置。
|
||||
|
||||
使用方法:
|
||||
python tools/config_generator.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 默认配置结构
|
||||
DEFAULT_CONFIG = {"keys": [], "accounts": []}
|
||||
|
||||
|
||||
def clear_screen():
|
||||
"""清屏"""
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def print_header():
|
||||
"""打印标题"""
|
||||
print("\n" + "=" * 50)
|
||||
print(" DS2API 配置生成器")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
def print_menu():
|
||||
"""打印菜单"""
|
||||
print("\n📋 请选择操作:")
|
||||
print(" 1. 添加 API Key")
|
||||
print(" 2. 添加账号 (Email)")
|
||||
print(" 3. 添加账号 (手机号)")
|
||||
print(" 4. 删除 API Key")
|
||||
print(" 5. 删除账号")
|
||||
print(" 6. 查看当前配置")
|
||||
print(" 7. 导出 JSON (可直接用于环境变量)")
|
||||
print(" 8. 导出 Base64 (推荐用于 Vercel)")
|
||||
print(" 9. 从 config.json 导入")
|
||||
print(" 10. 保存到 config.json")
|
||||
print(" 0. 退出")
|
||||
print()
|
||||
|
||||
|
||||
def add_api_key(config):
|
||||
"""添加 API Key"""
|
||||
print("\n➕ 添加 API Key")
|
||||
print(" 提示:API Key 是你自定义的密钥,用于调用此 API 服务")
|
||||
key = input(" 请输入 API Key: ").strip()
|
||||
if key:
|
||||
if key in config["keys"]:
|
||||
print(" ⚠️ 该 Key 已存在")
|
||||
else:
|
||||
config["keys"].append(key)
|
||||
print(f" ✅ 已添加 Key: {key[:8]}...")
|
||||
else:
|
||||
print(" ❌ 输入为空,未添加")
|
||||
|
||||
|
||||
def add_account_email(config):
|
||||
"""添加 Email 账号"""
|
||||
print("\n➕ 添加 DeepSeek 账号 (Email)")
|
||||
email = input(" Email: ").strip()
|
||||
password = input(" 密码: ").strip()
|
||||
if email and password:
|
||||
# 检查是否已存在
|
||||
for acc in config["accounts"]:
|
||||
if acc.get("email") == email:
|
||||
print(" ⚠️ 该账号已存在")
|
||||
return
|
||||
config["accounts"].append({"email": email, "password": password, "token": ""})
|
||||
print(f" ✅ 已添加账号: {email}")
|
||||
else:
|
||||
print(" ❌ 输入不完整,未添加")
|
||||
|
||||
|
||||
def add_account_mobile(config):
|
||||
"""添加手机号账号"""
|
||||
print("\n➕ 添加 DeepSeek 账号 (手机号)")
|
||||
mobile = input(" 手机号: ").strip()
|
||||
password = input(" 密码: ").strip()
|
||||
if mobile and password:
|
||||
# 检查是否已存在
|
||||
for acc in config["accounts"]:
|
||||
if acc.get("mobile") == mobile:
|
||||
print(" ⚠️ 该账号已存在")
|
||||
return
|
||||
config["accounts"].append({"mobile": mobile, "password": password, "token": ""})
|
||||
print(f" ✅ 已添加账号: {mobile}")
|
||||
else:
|
||||
print(" ❌ 输入不完整,未添加")
|
||||
|
||||
|
||||
def delete_api_key(config):
|
||||
"""删除 API Key"""
|
||||
if not config["keys"]:
|
||||
print("\n ⚠️ 当前没有 API Key")
|
||||
return
|
||||
print("\n🗑️ 删除 API Key")
|
||||
for i, key in enumerate(config["keys"], 1):
|
||||
print(f" {i}. {key[:8]}...")
|
||||
try:
|
||||
idx = int(input(" 选择要删除的序号 (0 取消): "))
|
||||
if 0 < idx <= len(config["keys"]):
|
||||
removed = config["keys"].pop(idx - 1)
|
||||
print(f" ✅ 已删除: {removed[:8]}...")
|
||||
elif idx != 0:
|
||||
print(" ❌ 无效选择")
|
||||
except ValueError:
|
||||
print(" ❌ 无效输入")
|
||||
|
||||
|
||||
def delete_account(config):
|
||||
"""删除账号"""
|
||||
if not config["accounts"]:
|
||||
print("\n ⚠️ 当前没有账号")
|
||||
return
|
||||
print("\n🗑️ 删除账号")
|
||||
for i, acc in enumerate(config["accounts"], 1):
|
||||
identifier = acc.get("email") or acc.get("mobile", "未知")
|
||||
print(f" {i}. {identifier}")
|
||||
try:
|
||||
idx = int(input(" 选择要删除的序号 (0 取消): "))
|
||||
if 0 < idx <= len(config["accounts"]):
|
||||
removed = config["accounts"].pop(idx - 1)
|
||||
identifier = removed.get("email") or removed.get("mobile", "未知")
|
||||
print(f" ✅ 已删除: {identifier}")
|
||||
elif idx != 0:
|
||||
print(" ❌ 无效选择")
|
||||
except ValueError:
|
||||
print(" ❌ 无效输入")
|
||||
|
||||
|
||||
def view_config(config):
|
||||
"""查看当前配置"""
|
||||
print("\n📄 当前配置")
|
||||
print("-" * 40)
|
||||
print(f" API Keys ({len(config['keys'])}个):")
|
||||
for key in config["keys"]:
|
||||
print(f" • {key[:8]}...")
|
||||
print(f"\n 账号 ({len(config['accounts'])}个):")
|
||||
for acc in config["accounts"]:
|
||||
identifier = acc.get("email") or acc.get("mobile", "未知")
|
||||
token_status = "✓ 有Token" if acc.get("token") else "✗ 无Token"
|
||||
print(f" • {identifier} [{token_status}]")
|
||||
print("-" * 40)
|
||||
|
||||
|
||||
def export_json(config):
|
||||
"""导出 JSON"""
|
||||
json_str = json.dumps(config, ensure_ascii=False, separators=(",", ":"))
|
||||
print("\n📤 JSON 格式 (可直接设置为 DS2API_CONFIG_JSON 环境变量):")
|
||||
print("-" * 50)
|
||||
print(json_str)
|
||||
print("-" * 50)
|
||||
|
||||
# 复制到剪贴板(如果可用)
|
||||
try:
|
||||
import subprocess
|
||||
process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE)
|
||||
process.communicate(json_str.encode("utf-8"))
|
||||
print(" ✅ 已复制到剪贴板 (macOS)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def export_base64(config):
|
||||
"""导出 Base64"""
|
||||
json_str = json.dumps(config, ensure_ascii=False, separators=(",", ":"))
|
||||
b64_str = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
||||
print("\n📤 Base64 格式 (推荐用于 Vercel 环境变量):")
|
||||
print("-" * 50)
|
||||
print(b64_str)
|
||||
print("-" * 50)
|
||||
|
||||
# 复制到剪贴板(如果可用)
|
||||
try:
|
||||
import subprocess
|
||||
process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE)
|
||||
process.communicate(b64_str.encode("utf-8"))
|
||||
print(" ✅ 已复制到剪贴板 (macOS)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def import_from_file(config):
|
||||
"""从 config.json 导入"""
|
||||
# 尝试多个可能的路径
|
||||
paths = [
|
||||
"config.json",
|
||||
"../config.json",
|
||||
os.path.join(os.path.dirname(__file__), "..", "config.json"),
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
config["keys"] = loaded.get("keys", [])
|
||||
config["accounts"] = loaded.get("accounts", [])
|
||||
print(f"\n ✅ 已从 {path} 导入配置")
|
||||
print(f" Keys: {len(config['keys'])}个, 账号: {len(config['accounts'])}个")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"\n ❌ 导入失败: {e}")
|
||||
return
|
||||
|
||||
print("\n ⚠️ 未找到 config.json 文件")
|
||||
|
||||
|
||||
def save_to_file(config):
|
||||
"""保存到 config.json"""
|
||||
# 确定保存路径
|
||||
path = "config.json"
|
||||
if not os.path.exists(path):
|
||||
parent_path = os.path.join(os.path.dirname(__file__), "..", "config.json")
|
||||
if os.path.exists(os.path.dirname(parent_path)):
|
||||
path = parent_path
|
||||
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n ✅ 已保存到 {path}")
|
||||
except Exception as e:
|
||||
print(f"\n ❌ 保存失败: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["keys"] = []
|
||||
config["accounts"] = []
|
||||
|
||||
print_header()
|
||||
print("\n💡 提示:此工具帮助你生成 DS2API 配置")
|
||||
print(" 生成的配置可用于本地 config.json 或 Vercel 环境变量")
|
||||
|
||||
while True:
|
||||
print_menu()
|
||||
choice = input("请输入选项: ").strip()
|
||||
|
||||
if choice == "1":
|
||||
add_api_key(config)
|
||||
elif choice == "2":
|
||||
add_account_email(config)
|
||||
elif choice == "3":
|
||||
add_account_mobile(config)
|
||||
elif choice == "4":
|
||||
delete_api_key(config)
|
||||
elif choice == "5":
|
||||
delete_account(config)
|
||||
elif choice == "6":
|
||||
view_config(config)
|
||||
elif choice == "7":
|
||||
export_json(config)
|
||||
elif choice == "8":
|
||||
export_base64(config)
|
||||
elif choice == "9":
|
||||
import_from_file(config)
|
||||
elif choice == "10":
|
||||
save_to_file(config)
|
||||
elif choice == "0":
|
||||
print("\n👋 再见!\n")
|
||||
break
|
||||
else:
|
||||
print("\n ❌ 无效选项,请重新选择")
|
||||
|
||||
input("\n按 Enter 继续...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"version": 2,
|
||||
"buildCommand": "bash scripts/build-webui.sh",
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/app.py"
|
||||
"destination": "/api/index"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
|
||||
@@ -42,6 +42,12 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
const apiFetch = authFetch || fetch
|
||||
const accounts = config.accounts || []
|
||||
const configuredKeys = config.keys || []
|
||||
const trimmedApiKey = apiKey.trim()
|
||||
const defaultKey = configuredKeys[0] || ''
|
||||
const effectiveKey = trimmedApiKey || defaultKey
|
||||
const customKeyActive = trimmedApiKey !== ''
|
||||
const customKeyManaged = customKeyActive && configuredKeys.includes(trimmedApiKey)
|
||||
const models = [
|
||||
{ id: "deepseek-chat", name: "deepseek-chat", icon: MessageSquare, desc: t('apiTester.models.chat'), color: "text-amber-500" },
|
||||
{ id: "deepseek-reasoner", name: "deepseek-reasoner", icon: Cpu, desc: t('apiTester.models.reasoner'), color: "text-amber-600" },
|
||||
@@ -58,6 +64,28 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
|
||||
const extractErrorMessage = async (res) => {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = await res.text()
|
||||
} catch {
|
||||
return t('apiTester.requestFailed')
|
||||
}
|
||||
if (!raw) {
|
||||
return t('apiTester.requestFailed')
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
const fromErrorObject = data?.error?.message
|
||||
const fromErrorString = typeof data?.error === 'string' ? data.error : ''
|
||||
const detail = typeof data?.detail === 'string' ? data.detail : ''
|
||||
const message = typeof data?.message === 'string' ? data.message : ''
|
||||
return fromErrorObject || fromErrorString || detail || message || t('apiTester.requestFailed')
|
||||
} catch {
|
||||
return raw.length > 240 ? `${raw.slice(0, 240)}...` : raw
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
if (loading) return
|
||||
|
||||
@@ -70,8 +98,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const key = apiKey || (config.keys?.[0] || '')
|
||||
if (!key) {
|
||||
if (!effectiveKey) {
|
||||
onMessage('error', t('apiTester.missingApiKey'))
|
||||
setLoading(false)
|
||||
setIsStreaming(false)
|
||||
@@ -80,7 +107,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${key}`,
|
||||
'Authorization': `Bearer ${effectiveKey}`,
|
||||
}
|
||||
if (selectedAccount) {
|
||||
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||
@@ -98,8 +125,7 @@ export default function ApiTester({ config, onMessage, authFetch }) {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
const errorMsg = data.error?.message || t('apiTester.requestFailed')
|
||||
const errorMsg = await extractErrorMessage(res)
|
||||
setResponse({ success: false, error: errorMsg })
|
||||
onMessage('error', errorMsg)
|
||||
setLoading(false)
|
||||
@@ -280,12 +306,22 @@ return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider ml-0.5">{t('apiTester.apiKeyOptional')}</label>
|
||||
<input
|
||||
type="password"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="w-full h-10 px-3 bg-muted/30 border border-border rounded-lg text-sm font-mono placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-ring transition-all"
|
||||
placeholder={config.keys?.[0] ? t('apiTester.apiKeyDefault', { suffix: config.keys[0].slice(-6) }) : t('apiTester.apiKeyPlaceholder')}
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
{customKeyActive && (
|
||||
<p className={clsx(
|
||||
"text-[11px] mt-1",
|
||||
customKeyManaged ? "text-emerald-600" : "text-amber-600"
|
||||
)}>
|
||||
{customKeyManaged ? t('apiTester.modeManaged') : t('apiTester.modeDirect')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
"apiKeyOptional": "API Key (optional)",
|
||||
"apiKeyDefault": "Default: ...{suffix}",
|
||||
"apiKeyPlaceholder": "Enter a custom key",
|
||||
"modeManaged": "Managed key mode (uses account pool).",
|
||||
"modeDirect": "Direct token mode (requires a valid DeepSeek token).",
|
||||
"statusError": "Error",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
"generating": "Generating response...",
|
||||
@@ -234,4 +236,4 @@
|
||||
"four": "Trigger a redeploy to apply the updated environment variables."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,8 @@
|
||||
"apiKeyOptional": "API 密钥 (可选)",
|
||||
"apiKeyDefault": "默认: ...{suffix}",
|
||||
"apiKeyPlaceholder": "输入自定义密钥",
|
||||
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
|
||||
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。",
|
||||
"statusError": "错误",
|
||||
"reasoningTrace": "思维链过程",
|
||||
"generating": "正在生成响应...",
|
||||
@@ -234,4 +236,4 @@
|
||||
"four": "触发重新部署以应用新的环境变量。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user