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:
CJACK.
2026-02-16 17:52:44 +08:00
committed by GitHub
76 changed files with 6672 additions and 8941 deletions

View File

@@ -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
View 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

1010
API.en.md

File diff suppressed because it is too large Load Diff

1038
API.md

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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) 并附复现步骤与日志。

View File

@@ -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
[![Deploy with Vercel](https://vercel.com/button)](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
View File

@@ -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 部署(推荐)
### 一键部署
[![Deploy with Vercel](https://vercel.com/button)](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

View File

@@ -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
View File

@@ -4,98 +4,126 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-部署推荐)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md)
语言 / Language: [中文](README.MD) | [English](README.en.md)
将 DeepSeek 免费对话版转换为 **OpenAI & Claude 兼容 API**,支持多账号轮询、自动 Token 刷新、可视化管理界面
将 DeepSeek Web 对话能力转换为 OpenAI Claude 兼容 API。当前仓库后端为 **Go 全量实现**,前端保留 React WebUI构建产物托管于 `static/admin`
![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532)
![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a)
![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9)
![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675)
## 当前实现边界
- 后端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 PoWWASM计算
- 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 部署(推荐)
[![Deploy with Vercel](https://vercel.com/button)](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
[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date)
本项目基于逆向方式实现,仅供学习与研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。

View File

@@ -4,96 +4,126 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-deployment-recommended)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](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`.
![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532)
![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a)
![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9)
![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675)
## 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
[![Deploy with Vercel](https://vercel.com/button)](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
[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](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
View 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
View File

@@ -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
View 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)
}
}

View File

@@ -1 +0,0 @@
# DS2API Core Modules

View File

@@ -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"] = ""

View File

@@ -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")

View File

@@ -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"
]

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View File

@@ -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()

View File

@@ -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"

View File

@@ -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
View 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
View 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
View 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
}

View 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"])
}
}

View 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)
}

View 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"
}
}

View 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
View 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
View 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)
}

View 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
View 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"))
}

View 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
View 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
View 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
View 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
}

View 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
View 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)
}

View 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
View 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
View 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:"))
}

View 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
View 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
}

View 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
View 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
}

View 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
View 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)
}

View File

@@ -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

View File

@@ -1 +0,0 @@
# DS2API Routes

View File

@@ -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"]

View File

@@ -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))

View File

@@ -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")

View File

@@ -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())

View File

@@ -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,
})

View File

@@ -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"}},
)

View File

@@ -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>&copy; 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.htmlSPA 路由)
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)

View File

@@ -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)

View File

@@ -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` 模式
- 增加测试超时时间

View File

@@ -1 +0,0 @@
# DS2API 测试模块

View File

@@ -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 "=================================================="

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 ![alt](http://example.com/image.png) 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 ![alt text](http://example.com/image.png) 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)

View File

@@ -1 +0,0 @@
# DS2API Tools

View File

@@ -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()

View File

@@ -1,10 +1,9 @@
{
"version": 2,
"buildCommand": "bash scripts/build-webui.sh",
"rewrites": [
{
"source": "/(.*)",
"destination": "/app.py"
"destination": "/api/index"
}
],
"headers": [

View File

@@ -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>

View File

@@ -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."
}
}
}
}

View File

@@ -145,6 +145,8 @@
"apiKeyOptional": "API 密钥 (可选)",
"apiKeyDefault": "默认: ...{suffix}",
"apiKeyPlaceholder": "输入自定义密钥",
"modeManaged": "当前使用托管 key 模式(会走账号池)。",
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token。",
"statusError": "错误",
"reasoningTrace": "思维链过程",
"generating": "正在生成响应...",
@@ -234,4 +236,4 @@
"four": "触发重新部署以应用新的环境变量。"
}
}
}
}