mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
feat: enhance tool call parsing robustness, authentication flexibility, and streaming output for tool content
This commit is contained in:
102
.env.example
102
.env.example
@@ -1,74 +1,54 @@
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ===============================================================
|
||||
# 数据配置(三选一)
|
||||
# ===============================================================
|
||||
# Optional JWT signing secret for admin token.
|
||||
# Defaults to DS2API_ADMIN_KEY when unset.
|
||||
# DS2API_JWT_SECRET=change-me
|
||||
|
||||
# 方式1: JSON 字符串(适合简单配置)
|
||||
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
|
||||
# Optional admin JWT validity in hours (default: 24)
|
||||
# DS2API_JWT_EXPIRE_HOURS=24
|
||||
|
||||
# 方式2: Base64 编码的 JSON(推荐用于 Vercel,避免特殊字符转义问题)
|
||||
# 生成方式: echo '{"keys":["your-api-key"],"accounts":[...]}' | base64
|
||||
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
|
||||
|
||||
# 方式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
|
||||
|
||||
113
DEPLOY.en.md
113
DEPLOY.en.md
@@ -2,31 +2,48 @@
|
||||
|
||||
Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
|
||||
|
||||
## Contents
|
||||
This guide is aligned with the current Go codebase.
|
||||
|
||||
- Vercel deployment
|
||||
- Docker deployment
|
||||
- Local run
|
||||
- systemd deployment
|
||||
## Deployment Modes
|
||||
|
||||
## Vercel Deployment
|
||||
- Local run: `go run ./cmd/ds2api`
|
||||
- Docker: `docker-compose up -d`
|
||||
- Vercel: serverless entry at `api/index.go`
|
||||
- Linux service mode: systemd
|
||||
|
||||
1. Import the repository into Vercel
|
||||
2. Set required environment variables:
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON` (JSON or Base64)
|
||||
3. Deploy and open `/admin`
|
||||
## 0. Prerequisites
|
||||
|
||||
The project uses `api/index.go` as the serverless entrypoint. See `vercel.json`.
|
||||
- Go 1.25+
|
||||
- Node.js 20+ (only if you need to build WebUI locally)
|
||||
- `config.json` or `DS2API_CONFIG_JSON`
|
||||
|
||||
## Docker Deployment
|
||||
## 1. Local Run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
cp config.example.json config.json
|
||||
# edit config.json
|
||||
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
Default port is `5001` (override with `PORT`).
|
||||
|
||||
Build WebUI if `/admin` reports missing assets:
|
||||
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
## 2. Docker Deployment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env
|
||||
|
||||
docker-compose up -d
|
||||
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
@@ -36,20 +53,50 @@ Rebuild after updates:
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Local Run
|
||||
Notes:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# edit config
|
||||
- `Dockerfile` uses multi-stage build (WebUI + Go binary)
|
||||
- Container entry command is `/usr/local/bin/ds2api`
|
||||
|
||||
go run ./cmd/ds2api
|
||||
## 3. Vercel Deployment
|
||||
|
||||
- Serverless entry: `api/index.go`
|
||||
- Rewrites and cache headers: `vercel.json`
|
||||
|
||||
Minimum environment variables:
|
||||
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON` (raw JSON or Base64)
|
||||
|
||||
Optional:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
- `VERCEL_TEAM_ID`
|
||||
|
||||
After deploy, verify:
|
||||
|
||||
- `/healthz`
|
||||
- `/v1/models`
|
||||
- `/admin`
|
||||
|
||||
## 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;
|
||||
}
|
||||
```
|
||||
|
||||
Default port is `5001` (override with `PORT`).
|
||||
|
||||
## systemd Deployment (Linux)
|
||||
|
||||
Example unit file:
|
||||
## 5. systemd Example (Linux)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@@ -61,7 +108,7 @@ Type=simple
|
||||
WorkingDirectory=/opt/ds2api
|
||||
Environment=PORT=5001
|
||||
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
|
||||
Environment=DS2API_ADMIN_KEY=your-admin-secret-key
|
||||
Environment=DS2API_ADMIN_KEY=admin
|
||||
ExecStart=/opt/ds2api/ds2api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
@@ -70,7 +117,7 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
@@ -78,3 +125,17 @@ sudo systemctl enable ds2api
|
||||
sudo systemctl start ds2api
|
||||
sudo systemctl status ds2api
|
||||
```
|
||||
|
||||
## 6. Post-Deploy Checks
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
If admin UI is required:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:5001/admin
|
||||
```
|
||||
|
||||
115
DEPLOY.md
115
DEPLOY.md
@@ -1,55 +1,102 @@
|
||||
# DS2API 部署指南(Go 版本)
|
||||
# DS2API 部署指南(Go)
|
||||
|
||||
语言 / Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md)
|
||||
|
||||
## 目录
|
||||
本指南基于当前 Go 代码库。
|
||||
|
||||
- Vercel 部署
|
||||
- Docker 部署
|
||||
- 本地运行
|
||||
- systemd 部署
|
||||
## 部署方式
|
||||
|
||||
## Vercel 部署
|
||||
- 本地运行:`go run ./cmd/ds2api`
|
||||
- Docker:`docker-compose up -d`
|
||||
- Vercel:`api/index.go` serverless 入口
|
||||
- Linux 服务化:systemd
|
||||
|
||||
1. 导入仓库到 Vercel
|
||||
2. 设置环境变量(至少):
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON`(JSON 或 Base64)
|
||||
3. 部署后访问 `/admin` 管理界面
|
||||
## 0. 前置要求
|
||||
|
||||
说明:项目使用 `api/index.go` 作为 Serverless 入口,配置见 `vercel.json`。
|
||||
- Go 1.25+
|
||||
- Node.js 20+(仅在需要本地构建 WebUI 时)
|
||||
- `config.json` 或 `DS2API_CONFIG_JSON`
|
||||
|
||||
## Docker 部署
|
||||
## 1. 本地运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
cp config.example.json config.json
|
||||
# 编辑 config.json
|
||||
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
默认监听 `5001`,可通过 `PORT` 覆盖。
|
||||
|
||||
构建 WebUI(可选,仅当 `/admin` 缺少静态文件时):
|
||||
|
||||
```bash
|
||||
./scripts/build-webui.sh
|
||||
```
|
||||
|
||||
## 2. Docker 部署
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
|
||||
docker-compose up -d
|
||||
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
更新后重建:
|
||||
更新镜像:
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 本地运行
|
||||
说明:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# 编辑配置
|
||||
- `Dockerfile` 使用多阶段构建(WebUI + Go 二进制)
|
||||
- 容器内默认启动命令:`/usr/local/bin/ds2api`
|
||||
|
||||
go run ./cmd/ds2api
|
||||
## 3. Vercel 部署
|
||||
|
||||
- serverless 入口:`api/index.go`
|
||||
- 路由与缓存头:`vercel.json`
|
||||
|
||||
至少配置环境变量:
|
||||
|
||||
- `DS2API_ADMIN_KEY`
|
||||
- `DS2API_CONFIG_JSON`(JSON 或 Base64)
|
||||
|
||||
可选:
|
||||
|
||||
- `VERCEL_TOKEN`
|
||||
- `VERCEL_PROJECT_ID`
|
||||
- `VERCEL_TEAM_ID`
|
||||
|
||||
部署后建议先访问:
|
||||
|
||||
- `/healthz`
|
||||
- `/v1/models`
|
||||
- `/admin`
|
||||
|
||||
## 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;
|
||||
}
|
||||
```
|
||||
|
||||
默认端口 `5001`,可通过 `PORT` 环境变量覆盖。
|
||||
|
||||
## systemd 部署(Linux)
|
||||
|
||||
示例服务文件:
|
||||
## 5. systemd 示例(Linux)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
@@ -61,7 +108,7 @@ Type=simple
|
||||
WorkingDirectory=/opt/ds2api
|
||||
Environment=PORT=5001
|
||||
Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json
|
||||
Environment=DS2API_ADMIN_KEY=your-admin-secret-key
|
||||
Environment=DS2API_ADMIN_KEY=admin
|
||||
ExecStart=/opt/ds2api/ds2api
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
@@ -78,3 +125,17 @@ sudo systemctl enable ds2api
|
||||
sudo systemctl start ds2api
|
||||
sudo systemctl status ds2api
|
||||
```
|
||||
|
||||
## 6. 部署后检查
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
如果你依赖管理台接口,再检查:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:5001/admin
|
||||
```
|
||||
|
||||
259
README.MD
259
README.MD
@@ -4,95 +4,94 @@
|
||||

|
||||

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

|
||||

|
||||

|
||||

|
||||
## 当前实现边界
|
||||
|
||||
- 后端:Go(`cmd/`, `api/`, `internal/`),不再依赖 Python 运行时
|
||||
- 前端:React 管理台(源码在 `webui/`,运行时托管静态构建)
|
||||
- 部署:本地运行、Docker、Vercel Serverless
|
||||
|
||||
## 核心能力
|
||||
|
||||
## ✨ 特性
|
||||
- OpenAI 兼容:`/v1/models`、`/v1/chat/completions`
|
||||
- Claude 兼容:`/anthropic/v1/models`、`/anthropic/v1/messages`、`/anthropic/v1/messages/count_tokens`
|
||||
- 多账号轮询与自动 token 刷新
|
||||
- DeepSeek PoW(WASM)计算
|
||||
- Admin API:配置管理、账号测试、导入导出、Vercel 同步
|
||||
- WebUI:`/admin` 单页应用托管
|
||||
- 运维探针:`/healthz`、`/readyz`
|
||||
|
||||
- 🔄 **双协议兼容** - 同时支持 OpenAI 和 Claude (Anthropic) API 格式
|
||||
- 🚀 **多账号轮询** - Round-Robin 负载均衡,支持高并发场景
|
||||
- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护
|
||||
- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel 配置
|
||||
- 🌍 **多语言切换** - WebUI 内置中英双语,可随时切换
|
||||
- 🔍 **联网搜索** - 支持 DeepSeek 原生搜索增强模式
|
||||
- 🧠 **深度思考** - 支持推理模式,输出思考过程
|
||||
- 🛠️ **工具调用** - 兼容 OpenAI Function Calling 格式
|
||||
- ☁️ **Vercel 一键部署** - 无需服务器,快速上线
|
||||
## 模型支持
|
||||
|
||||
## 📋 模型支持
|
||||
### OpenAI 接口
|
||||
|
||||
### OpenAI 兼容接口 (`/v1/chat/completions`)
|
||||
| 模型 | thinking | search |
|
||||
| --- | --- | --- |
|
||||
| `deepseek-chat` | false | false |
|
||||
| `deepseek-reasoner` | true | false |
|
||||
| `deepseek-chat-search` | false | true |
|
||||
| `deepseek-reasoner-search` | true | true |
|
||||
|
||||
| 模型 | 深度思考 | 联网搜索 | 说明 |
|
||||
|-----|:--------:|:--------:|------|
|
||||
| `deepseek-chat` | ❌ | ❌ | 标准对话模式 |
|
||||
| `deepseek-reasoner` | ✅ | ❌ | 推理模式(输出思考过程) |
|
||||
| `deepseek-chat-search` | ❌ | ✅ | 联网搜索模式 |
|
||||
| `deepseek-reasoner-search` | ✅ | ✅ | 推理 + 联网搜索 |
|
||||
### Claude 接口
|
||||
|
||||
### Claude 兼容接口 (`/anthropic/v1/messages`)
|
||||
| 模型 | 默认映射 |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-4-20250514` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-fast` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` |
|
||||
|
||||
| 模型 | 说明 |
|
||||
|-----|------|
|
||||
| `claude-sonnet-4-20250514` | 映射到 deepseek-chat(标准模式) |
|
||||
| `claude-sonnet-4-20250514-fast` | 映射到 deepseek-chat(快速模式) |
|
||||
| `claude-sonnet-4-20250514-slow` | 映射到 deepseek-reasoner(推理模式) |
|
||||
可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射。
|
||||
|
||||
> **提示**:Claude 接口实际调用的是 DeepSeek,响应格式会自动转换为 Anthropic 标准格式。
|
||||
## 快速开始
|
||||
|
||||
## 🚀 快速开始
|
||||
### 1) 本地运行
|
||||
|
||||
### 方式一:Vercel 部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api)
|
||||
|
||||
1. 点击上方按钮,设置管理密码 `DS2API_ADMIN_KEY`
|
||||
2. 部署完成后访问 `/admin` 管理界面
|
||||
3. 添加 DeepSeek 账号和自定义 API Key
|
||||
4. 点击「同步到 Vercel」保存配置
|
||||
|
||||
> **首次同步会自动验证账号并保存 Token,后续操作无需重复输入凭证。**
|
||||
|
||||
### 方式二:本地开发
|
||||
要求:Go 1.25+
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 准备配置
|
||||
cp config.example.json config.json
|
||||
# 编辑 config.json,添加 DeepSeek 账号信息
|
||||
# 编辑 config.json
|
||||
|
||||
# 3. 启动服务(Go 版本)
|
||||
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)
|
||||
|
||||
## 配置说明
|
||||
|
||||
### `config.json` 示例
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -108,125 +107,59 @@ go run ./cmd/ds2api
|
||||
"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_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": "你好"}]
|
||||
}'
|
||||
```
|
||||
## Tool Call 适配说明
|
||||
|
||||
### Python SDK 使用
|
||||
当前实现对 toolcall 做了防泄漏处理:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
- `tools` + `stream=true` 时,服务端会先缓冲正文片段
|
||||
- 若识别到工具调用,会只输出结构化 `tool_calls`,不透传原始 JSON 文本
|
||||
- 若最终不是工具调用,再一次性输出普通文本
|
||||
- 解析器支持混合文本、fenced JSON、`function.arguments` 字符串等格式
|
||||
|
||||
client = OpenAI(
|
||||
api_key="your-api-key",
|
||||
base_url="http://localhost:5001/v1"
|
||||
)
|
||||
## 文档与测试
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-reasoner",
|
||||
messages=[{"role": "user", "content": "请解释量子纠缠"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
|
||||
## 🔧 部署配置
|
||||
|
||||
### Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:Docker 部署
|
||||
- API 文档:`API.md` / `API.en.md`
|
||||
- 部署文档:`DEPLOY.md` / `DEPLOY.en.md`
|
||||
- 贡献指南:`CONTRIBUTING.md` / `CONTRIBUTING.en.md`
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库并进入目录
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON
|
||||
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker-compose logs -f
|
||||
go test ./...
|
||||
```
|
||||
|
||||
> **Docker 优势**:零侵入设计,主代码更新只需 `docker-compose up -d --build`,无需修改 Docker 配置。详见 [DEPLOY.md](DEPLOY.md#docker-部署推荐)。
|
||||
## 免责声明
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
**本项目基于逆向工程实现,服务稳定性无法保证。**
|
||||
|
||||
- 仅供学习研究使用,**禁止商业用途或对外提供服务**
|
||||
- 建议正式项目使用 [DeepSeek 官方 API](https://platform.deepseek.com/)
|
||||
- 使用本项目产生的任何风险由用户自行承担
|
||||
|
||||
## 📜 鸣谢
|
||||
|
||||
本项目基于以下开源项目:
|
||||
|
||||
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
|
||||
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
|
||||
|
||||
## 📊 Star History
|
||||
|
||||
[](https://star-history.com/#CJackHwang/ds2api&Date)
|
||||
本项目基于逆向方式实现,仅供学习与研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。
|
||||
|
||||
257
README.en.md
257
README.en.md
@@ -4,93 +4,94 @@
|
||||

|
||||

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

|
||||

|
||||

|
||||

|
||||
## Implementation Boundary
|
||||
|
||||
## ✨ Features
|
||||
- Backend: Go (`cmd/`, `api/`, `internal/`), no Python runtime
|
||||
- Frontend: React admin panel (`webui/` source, static build served at runtime)
|
||||
- Deployment: local run, Docker, Vercel serverless
|
||||
|
||||
- 🔄 **Dual-protocol support** - OpenAI and Claude (Anthropic) compatible APIs
|
||||
- 🚀 **Multi-account rotation** - Round-robin load balancing for high concurrency
|
||||
- 🔐 **Automatic token refresh** - Re-auth on expiry without manual maintenance
|
||||
- 🌐 **WebUI management** - Add accounts, test APIs, and sync Vercel settings visually
|
||||
- 🌍 **Language toggle** - Built-in Chinese and English UI switcher
|
||||
- 🔍 **Web search** - DeepSeek native search enhancement mode
|
||||
- 🧠 **Deep reasoning** - Reasoning mode with trace output
|
||||
- 🛠️ **Tool calling** - OpenAI Function Calling compatible
|
||||
- ☁️ **One-click Vercel deploy** - No server required
|
||||
## Key Capabilities
|
||||
|
||||
## 📋 Model Support
|
||||
- OpenAI-compatible endpoints: `GET /v1/models`, `POST /v1/chat/completions`
|
||||
- Claude-compatible endpoints: `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens`
|
||||
- Multi-account rotation and automatic token refresh
|
||||
- DeepSeek PoW solving via WASM
|
||||
- Admin API: config management, account tests, import/export, Vercel sync
|
||||
- WebUI SPA hosting at `/admin`
|
||||
- Health probes: `GET /healthz`, `GET /readyz`
|
||||
|
||||
### OpenAI compatible endpoint (`/v1/chat/completions`)
|
||||
## Model Support
|
||||
|
||||
| Model | Reasoning | Search | Notes |
|
||||
|-----|:--------:|:------:|------|
|
||||
| `deepseek-chat` | ❌ | ❌ | Standard chat |
|
||||
| `deepseek-reasoner` | ✅ | ❌ | Reasoning (shows trace) |
|
||||
| `deepseek-chat-search` | ❌ | ✅ | Web search mode |
|
||||
| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search |
|
||||
### OpenAI endpoint
|
||||
|
||||
### Claude compatible endpoint (`/anthropic/v1/messages`)
|
||||
| Model | thinking | search |
|
||||
| --- | --- | --- |
|
||||
| `deepseek-chat` | false | false |
|
||||
| `deepseek-reasoner` | true | false |
|
||||
| `deepseek-chat-search` | false | true |
|
||||
| `deepseek-reasoner-search` | true | true |
|
||||
|
||||
| Model | Notes |
|
||||
|-----|------|
|
||||
| `claude-sonnet-4-20250514` | Maps to deepseek-chat (standard) |
|
||||
| `claude-sonnet-4-20250514-fast` | Maps to deepseek-chat (fast) |
|
||||
| `claude-sonnet-4-20250514-slow` | Maps to deepseek-reasoner (reasoning) |
|
||||
### Claude endpoint
|
||||
|
||||
> **Tip**: The Claude endpoint actually calls DeepSeek and returns Anthropic-format responses.
|
||||
| Model | Default mapping |
|
||||
| --- | --- |
|
||||
| `claude-sonnet-4-20250514` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-fast` | `deepseek-chat` |
|
||||
| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` |
|
||||
|
||||
## 🚀 Quick Start
|
||||
You can override mapping via `claude_mapping` or `claude_model_mapping` in config.
|
||||
|
||||
### Option 1: Vercel deployment (recommended)
|
||||
## Quick Start
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api)
|
||||
### 1) Local run
|
||||
|
||||
1. Click the button above and set `DS2API_ADMIN_KEY`
|
||||
2. After deployment, visit `/admin`
|
||||
3. Add DeepSeek accounts and custom API keys
|
||||
4. Click "Sync to Vercel" to persist configuration
|
||||
|
||||
> **First sync validates accounts and stores tokens automatically.**
|
||||
|
||||
### Option 2: Local development
|
||||
Requirement: Go 1.25+
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Configure accounts
|
||||
cp config.example.json config.json
|
||||
# Edit config.json to add DeepSeek account info
|
||||
# edit config.json
|
||||
|
||||
# 3. Start the service (Go runtime)
|
||||
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)
|
||||
|
||||
## Configuration
|
||||
|
||||
### `config.json` example
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -106,125 +107,59 @@ 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_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"}]
|
||||
}'
|
||||
```
|
||||
## Tool Call Adaptation
|
||||
|
||||
### Python SDK usage
|
||||
Tool-call leakage is handled in the current implementation:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
- 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
|
||||
|
||||
client = OpenAI(
|
||||
api_key="your-api-key",
|
||||
base_url="http://localhost:5001/v1"
|
||||
)
|
||||
## Docs and Testing
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model="deepseek-reasoner",
|
||||
messages=[{"role": "user", "content": "Explain quantum entanglement"}],
|
||||
stream=True
|
||||
)
|
||||
|
||||
for chunk in response:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="")
|
||||
```
|
||||
|
||||
## 🔧 Deployment Notes
|
||||
|
||||
### Nginx reverse proxy
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:5001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Docker deployment
|
||||
- API docs: `API.md` / `API.en.md`
|
||||
- Deployment docs: `DEPLOY.md` / `DEPLOY.en.md`
|
||||
- Contributing: `CONTRIBUTING.md` / `CONTRIBUTING.en.md`
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo and enter the directory
|
||||
git clone https://github.com/CJackHwang/ds2api.git
|
||||
cd ds2api
|
||||
|
||||
# 2. Configure environment variables
|
||||
cp .env.example .env
|
||||
# Edit .env and fill in DS2API_ADMIN_KEY and DS2API_CONFIG_JSON
|
||||
|
||||
# 3. Start the service
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Check logs
|
||||
docker-compose logs -f
|
||||
go test ./...
|
||||
```
|
||||
|
||||
> **Docker advantage**: Zero-intrusion design; update the main code with `docker-compose up -d --build` without changing Docker configuration. See [DEPLOY.md](DEPLOY.md#docker-deployment-recommended).
|
||||
## Disclaimer
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
**This project is based on reverse engineering and stability is not guaranteed.**
|
||||
|
||||
- For learning and research only. **No commercial use or public service is allowed.**
|
||||
- For production, use the official [DeepSeek API](https://platform.deepseek.com/)
|
||||
- You assume all risks from using this project
|
||||
|
||||
## 📜 Acknowledgements
|
||||
|
||||
This project is based on the following open-source projects:
|
||||
|
||||
- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api)
|
||||
- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api)
|
||||
|
||||
## 📊 Star History
|
||||
|
||||
[](https://star-history.com/#CJackHwang/ds2api&Date)
|
||||
This project is built through reverse engineering and is provided for learning and research only. Stability is not guaranteed. Do not use it in scenarios that violate terms of service or laws.
|
||||
|
||||
@@ -211,6 +211,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
|
||||
|
||||
created := time.Now().Unix()
|
||||
firstChunkSent := false
|
||||
bufferToolContent := len(toolNames) > 0
|
||||
currentType := "text"
|
||||
if thinkingEnabled {
|
||||
currentType = "thinking"
|
||||
@@ -240,12 +241,34 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
|
||||
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": map[string]any{"tool_calls": util.FormatOpenAIToolCalls(detected)}, "index": 0}},
|
||||
"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)
|
||||
@@ -325,7 +348,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
|
||||
}
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
delta["content"] = p.Text
|
||||
if !bufferToolContent {
|
||||
delta["content"] = p.Text
|
||||
}
|
||||
}
|
||||
if len(delta) > 0 {
|
||||
newChoices = append(newChoices, map[string]any{"delta": delta, "index": 0})
|
||||
|
||||
@@ -17,7 +17,7 @@ func AdminKey() string {
|
||||
if v := strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "your-admin-secret-key"
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func jwtSecret() string {
|
||||
|
||||
@@ -15,7 +15,7 @@ type ctxKey string
|
||||
const authCtxKey ctxKey = "auth_context"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized: missing Bearer token")
|
||||
ErrUnauthorized = errors.New("unauthorized: missing auth token")
|
||||
ErrNoAccount = errors.New("no accounts configured or all accounts are busy")
|
||||
)
|
||||
|
||||
@@ -41,11 +41,10 @@ func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Reso
|
||||
}
|
||||
|
||||
func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
callerKey := extractCallerToken(req)
|
||||
if callerKey == "" {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
callerKey := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
|
||||
ctx := req.Context()
|
||||
if !r.Store.HasAPIKey(callerKey) {
|
||||
return &RequestAuth{UseConfigToken: false, DeepSeekToken: callerKey, resolver: r, TriedAccounts: map[string]bool{}}, nil
|
||||
@@ -148,3 +147,14 @@ func (r *Resolver) Release(a *RequestAuth) {
|
||||
}
|
||||
r.Pool.Release(a.AccountID)
|
||||
}
|
||||
|
||||
func extractCallerToken(req *http.Request) string {
|
||||
authHeader := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token := strings.TrimSpace(authHeader[7:])
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(req.Header.Get("x-api-key"))
|
||||
}
|
||||
|
||||
74
internal/auth/request_test.go
Normal file
74
internal/auth/request_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/account"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func newTestResolver(t *testing.T) *Resolver {
|
||||
t.Helper()
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["managed-key"],
|
||||
"accounts":[{"email":"acc@example.com","password":"pwd","token":"account-token"}]
|
||||
}`)
|
||||
store := config.LoadStore()
|
||||
pool := account.NewPool(store)
|
||||
return NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
|
||||
return "fresh-token", nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetermineWithXAPIKeyUsesDirectToken(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
req.Header.Set("x-api-key", "direct-token")
|
||||
|
||||
auth, err := r.Determine(req)
|
||||
if err != nil {
|
||||
t.Fatalf("determine failed: %v", err)
|
||||
}
|
||||
if auth.UseConfigToken {
|
||||
t.Fatalf("expected direct token mode")
|
||||
}
|
||||
if auth.DeepSeekToken != "direct-token" {
|
||||
t.Fatalf("unexpected token: %q", auth.DeepSeekToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
req.Header.Set("x-api-key", "managed-key")
|
||||
|
||||
auth, err := r.Determine(req)
|
||||
if err != nil {
|
||||
t.Fatalf("determine failed: %v", err)
|
||||
}
|
||||
defer r.Release(auth)
|
||||
if !auth.UseConfigToken {
|
||||
t.Fatalf("expected managed key mode")
|
||||
}
|
||||
if auth.AccountID != "acc@example.com" {
|
||||
t.Fatalf("unexpected account id: %q", auth.AccountID)
|
||||
}
|
||||
if auth.DeepSeekToken != "account-token" {
|
||||
t.Fatalf("unexpected account token: %q", auth.DeepSeekToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineMissingToken(t *testing.T) {
|
||||
r := newTestResolver(t)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
_, err := r.Determine(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthorized error")
|
||||
}
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -356,5 +356,5 @@ func (s *Store) ClaudeMapping() map[string]string {
|
||||
if len(s.cfg.ClaudeMapping) > 0 {
|
||||
return cloneStringMap(s.cfg.ClaudeMapping)
|
||||
}
|
||||
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-chat"}
|
||||
return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
@@ -19,23 +20,25 @@ func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
m := toolCallPattern.FindStringSubmatch(text)
|
||||
if len(m) < 2 {
|
||||
return nil
|
||||
}
|
||||
payload := "{" + `"tool_calls":[` + m[1] + "]}"
|
||||
var obj struct {
|
||||
ToolCalls []ParsedToolCall `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &obj); err != 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(obj.ToolCalls))
|
||||
for _, tc := range obj.ToolCalls {
|
||||
out := make([]ParsedToolCall, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
@@ -52,6 +55,220 @@ func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
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 {
|
||||
|
||||
@@ -11,6 +11,34 @@ func TestParseToolCalls(t *testing.T) {
|
||||
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 TestParseToolCallsRejectUnknown(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user