feat: enhance tool call parsing robustness, authentication flexibility, and streaming output for tool content

This commit is contained in:
CJACK
2026-02-16 01:24:52 +08:00
parent bd788a12b1
commit 57f2041edb
14 changed files with 1753 additions and 1519 deletions

View File

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

1003
API.en.md

File diff suppressed because it is too large Load Diff

1031
API.md

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -4,95 +4,94 @@
![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.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
[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date)
本项目基于逆向方式实现,仅供学习与研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。

View File

@@ -4,93 +4,94 @@
![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.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
[![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.

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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