Compare commits

...

26 Commits

Author SHA1 Message Date
CJACK.
9f91da403f Merge pull request #59 from ronghuaxueleng/feature/account-improvements
feat: 账号测试状态持久化、分页选择器、点击账号名复制
2026-02-27 23:16:05 +08:00
CJACK.
89e5ad24b9 Merge pull request #57 from jacob-sheng/feat/zeabur-oneclick
feat(zeabur): 一键部署模板
2026-02-27 23:12:13 +08:00
CJACK.
3f106ac112 Merge pull request #55 from BigUncle/fix/claude-toolcall
fix(claude): 修复工具调用兼容与解析回退
2026-02-27 23:11:46 +08:00
root
f6f6a651fd feat: 账号测试状态持久化、分页选择器、点击账号名复制
- Account 结构加 TestStatus 字段,测试后写入 config.json
- listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点
- 分页选择器支持 10/20/50/100/500/1000/2000/5000
- 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾
2026-02-27 21:30:43 +08:00
AYANGarch
f60a3ea501 docs(readme): add ds2api whale icon 2026-02-26 23:18:57 +08:00
AYANGarch
3f09d60cdc feat(zeabur): add one-click deploy template 2026-02-26 22:54:50 +08:00
BigUncle
d3b5493d2e fix(claude): guard thinking tool-call fallback when final text exists
- only parse tool_calls from thinking when finalText is empty

- apply the same guard in stream runtime finalizer

- add regression tests for non-stream and stream paths
2026-02-26 00:41:39 +08:00
BigUncle
255feb2e65 fix(claude): 修复工具调用兼容与解析回退
- Claude 工具定义兼容 input_schema 与 function.parameters

- tool_calls 解析增加 thinking 回退与大小写无关工具名匹配

- 补充 claude/util 相关回归测试
2026-02-25 18:03:25 +08:00
CJACK.
4b73315df0 Merge pull request #51 from CJackHwang/dev
feat: Implement multi-stage Docker build for releases, reusing pre-bu…
2026-02-23 04:06:18 +08:00
CJACK
a086e0cfa1 feat: Refactor Dockerfile to use BusyBox for core utilities and update healthcheck commands in Docker Compose and deployment documentation. 2026-02-23 04:05:22 +08:00
CJACK
f3bc022a36 feat: Implement multi-stage Docker build for releases, reusing pre-built artifacts from CI and updating documentation. 2026-02-23 03:52:55 +08:00
CJACK
b7cb7ef0c1 ci: use gh cli for release asset upload 2026-02-23 02:20:05 +08:00
CJACK
267420a46a ci: add workflow_dispatch with release tag input 2026-02-23 02:01:01 +08:00
CJACK
3c66ab958a ci: fix GHCR probe and require explicit release tag upload 2026-02-23 01:58:08 +08:00
CJACK.
cf2f79b6f4 Merge pull request #50 from CJackHwang/dev
更新
2026-02-23 01:38:40 +08:00
CJACK
ab6e817c8e 更新 2026-02-23 01:36:46 +08:00
CJACK.
9ae4630a3b Merge pull request #48 from CJackHwang/dev
Merge pull request #47 from CJackHwang/codex/fix-ci-workflow-errors-during-build

ci: 增强 release-artifacts 工作流对 GHCR 超时与上传失败的容错
2026-02-23 00:50:59 +08:00
CJACK.
d1b8537cfb Merge pull request #47 from CJackHwang/codex/fix-ci-workflow-errors-during-build
ci: 增强 release-artifacts 工作流对 GHCR 超时与上传失败的容错
2026-02-23 00:49:51 +08:00
CJACK.
d32b4481da ci: 提升发布流程对 GHCR 网络波动的容错 2026-02-23 00:49:09 +08:00
CJACK.
52a04ac575 Merge pull request #46 from CJackHwang/dev
feat: prevent raw tool call JSON leakage for unknown or rejected tool calls and consolidate container publishing to GHCR.
2026-02-23 00:30:17 +08:00
CJACK
0d3d535c08 feat: prevent raw tool call JSON leakage for unknown or rejected tool calls and consolidate container publishing to GHCR. 2026-02-23 00:27:46 +08:00
CJACK.
224462018a Merge pull request #45 from CJackHwang/dev
Merge pull request #44 from CJackHwang/codex/investigate-release-workflow-error

ci: 增加 Node 单测失败摘要输出
2026-02-22 23:36:36 +08:00
CJACK.
35e89230fd Merge pull request #44 from CJackHwang/codex/investigate-release-workflow-error
ci: 增加 Node 单测失败摘要输出
2026-02-22 23:31:34 +08:00
CJACK.
9a57af6092 ci: 增加 Node 单测失败摘要输出 2026-02-22 23:28:40 +08:00
CJACK.
2e1bd8a481 Merge pull request #42 from CJackHwang/codex/fix-sieve-tool-call-filtering-issues
fix(node): 移除被过滤工具调用的回退重发并对齐 Go 行为
2026-02-22 23:07:49 +08:00
CJACK.
1e678ecc1a fix(node): 移除被过滤工具调用的回退重发并对齐 Go 行为 2026-02-22 23:05:40 +08:00
34 changed files with 714 additions and 73 deletions

View File

@@ -10,7 +10,9 @@ __pycache__
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/*
!dist/docker-input/
!dist/docker-input/*.tar.gz
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/

View File

@@ -4,6 +4,12 @@ on:
release: release:
types: types:
- published - published
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to build/publish (e.g. v2.1.6)"
required: true
type: string
permissions: permissions:
contents: write contents: write
@@ -13,8 +19,7 @@ jobs:
build-and-upload: build-and-upload:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -45,7 +50,7 @@ jobs:
- name: Build Multi-Platform Archives - name: Build Multi-Platform Archives
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ github.event.release.tag_name }}" TAG="${RELEASE_TAG}"
mkdir -p dist mkdir -p dist
targets=( targets=(
@@ -82,25 +87,44 @@ jobs:
rm -rf "${STAGE}" rm -rf "${STAGE}"
done done
- name: Prepare Docker release inputs
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
mkdir -p dist/docker-input
cp "dist/ds2api_${TAG}_linux_amd64.tar.gz" "dist/docker-input/linux_amd64.tar.gz"
cp "dist/ds2api_${TAG}_linux_arm64.tar.gz" "dist/docker-input/linux_arm64.tar.gz"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to GHCR - name: Wait for GHCR endpoint
uses: docker/login-action@v3 run: |
with: set -euo pipefail
registry: ghcr.io for i in {1..6}; do
username: ${{ github.actor }} code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 https://ghcr.io/v2/ || true)"
password: ${{ secrets.GITHUB_TOKEN }} if [ "${code}" = "200" ] || [ "${code}" = "401" ] || [ "${code}" = "405" ]; then
exit 0
fi
sleep "$((i * 10))"
done
echo "GHCR endpoint is unreachable after multiple retries (last status: ${code:-unknown})." >&2
exit 1
- name: Log in to Docker Hub - name: Log in to GHCR (with retry)
if: "${{ env.DOCKERHUB_USERNAME != '' }}" run: |
uses: docker/login-action@v3 set -euo pipefail
with: for i in {1..6}; do
username: ${{ env.DOCKERHUB_USERNAME }} if echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin; then
password: ${{ env.DOCKERHUB_TOKEN }} exit 0
fi
sleep "$((i * 10))"
done
echo "Failed to login to GHCR after multiple retries." >&2
exit 1
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta_release id: meta_release
@@ -108,16 +132,19 @@ jobs:
with: with:
images: | images: |
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
${{ env.DOCKERHUB_USERNAME || 'cjackhwang' }}/ds2api
tags: | tags: |
type=raw,value=${{ github.event.release.tag_name }} type=raw,value=${{ env.RELEASE_TAG }}
type=raw,value=latest type=raw,value=latest
- name: Build and Push Docker Image - name: Build and Push Docker Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
DOCKER_BUILD_SUMMARY: "false"
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
target: runtime-from-dist
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta_release.outputs.tags }} tags: ${{ steps.meta_release.outputs.tags }}
@@ -126,15 +153,17 @@ jobs:
- name: Export Docker image archives for release assets - name: Export Docker image archives for release assets
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ github.event.release.tag_name }}" TAG="${RELEASE_TAG}"
docker buildx build \ docker buildx build \
--platform linux/amd64 \ --platform linux/amd64 \
--target runtime-from-dist \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_amd64.tar" \ --output type=docker,dest="dist/ds2api_${TAG}_docker_linux_amd64.tar" \
. .
docker buildx build \ docker buildx build \
--platform linux/arm64 \ --platform linux/arm64 \
--target runtime-from-dist \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_arm64.tar" \ --output type=docker,dest="dist/ds2api_${TAG}_docker_linux_arm64.tar" \
. .
@@ -146,10 +175,29 @@ jobs:
set -euo pipefail set -euo pipefail
(cd dist && sha256sum *.tar.gz *.zip > sha256sums.txt) (cd dist && sha256sum *.tar.gz *.zip > sha256sums.txt)
- name: Validate release tag
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
if [ -z "${TAG}" ]; then
echo "release tag is empty; set release_tag when using workflow_dispatch." >&2
exit 1
fi
- name: Upload Release Assets - name: Upload Release Assets
uses: softprops/action-gh-release@v2 env:
with: GH_TOKEN: ${{ github.token }}
files: | run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
FILES=(
dist/*.tar.gz dist/*.tar.gz
dist/*.zip dist/*.zip
dist/sha256sums.txt dist/sha256sums.txt
)
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" "${FILES[@]}" --clobber
else
gh release create "${TAG}" "${FILES[@]}" --title "${TAG}" --notes ""
fi

View File

@@ -135,11 +135,12 @@ docker-compose up -d --build
### 2.3 Docker Architecture ### 2.3 Docker Architecture
The `Dockerfile` uses a three-stage build: The `Dockerfile` now provides two image paths:
1. **WebUI build stage**: `node:20` image, runs `npm ci && npm run build` 1. **Default local/dev path (`runtime-from-source`)**: a three-stage build (WebUI build + Go build + runtime).
2. **Go build stage**: `golang:1.24` image, compiles the binary 2. **Release path (`runtime-from-dist`)**: CI first creates `dist/ds2api_<tag>_linux_<arch>.tar.gz`, then Docker directly reuses the binary and `static/admin` assets from those release archives, without running `npm build`/`go build` again.
3. **Runtime stage**: `debian:bookworm-slim` minimal image
The release path keeps Docker images aligned with release archives and reduces duplicate build work.
Container entry command: `/usr/local/bin/ds2api`, default exposed port: `5001`. Container entry command: `/usr/local/bin/ds2api`, default exposed port: `5001`.
@@ -160,7 +161,7 @@ Docker Compose includes a built-in health check:
```yaml ```yaml
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"] test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -174,6 +175,18 @@ If container logs look normal but the admin panel is unreachable, check these fi
1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`). 1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`).
2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`. 2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`.
### 2.7 Zeabur One-Click (Dockerfile)
This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur:
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
Notes:
- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`.
- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path.
- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login).
--- ---
## 3. Vercel Deployment ## 3. Vercel Deployment
@@ -341,6 +354,7 @@ Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on Release `published` (no build on normal push) - **Trigger**: only on Release `published` (no build on normal push)
- **Outputs**: multi-platform binary archives + `sha256sums.txt` - **Outputs**: multi-platform binary archives + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
| Platform | Architecture | Format | | Platform | Architecture | Format |
| --- | --- | --- | | --- | --- | --- |
@@ -378,6 +392,16 @@ cp config.example.json config.json
2. Wait for the `Release Artifacts` workflow to complete 2. Wait for the `Release Artifacts` workflow to complete
3. Download the matching archive from Release Assets 3. Download the matching archive from Release Assets
### Pull from GHCR (Optional)
```bash
# latest
docker pull ghcr.io/cjackhwang/ds2api:latest
# specific version (example)
docker pull ghcr.io/cjackhwang/ds2api:v2.1.2
```
--- ---
## 5. Reverse Proxy (Nginx) ## 5. Reverse Proxy (Nginx)

View File

@@ -135,11 +135,12 @@ docker-compose up -d --build
### 2.3 Docker 架构说明 ### 2.3 Docker 架构说明
`Dockerfile` 使用三阶段构建 `Dockerfile` 提供两条构建路径
1. **WebUI 构建阶段**`node:20` 镜像,执行 `npm ci && npm run build` 1. **本地/开发默认路径(`runtime-from-source`**三阶段构建WebUI 构建 + Go 构建 + 运行阶段)。
2. **Go 构建阶段**`golang:1.24` 镜像,编译二进制文件 2. **Release 路径(`runtime-from-dist`**CI 先生成 `dist/ds2api_<tag>_linux_<arch>.tar.gz`,再由 Docker 直接复用该发布包内的二进制和 `static/admin` 产物组装运行镜像,不再重复执行 `npm build`/`go build`
3. **运行阶段**`debian:bookworm-slim` 精简镜像
Release 路径可确保 Docker 镜像与 release 压缩包使用同一套产物,减少重复构建带来的差异。
容器内启动命令:`/usr/local/bin/ds2api`,默认暴露端口 `5001` 容器内启动命令:`/usr/local/bin/ds2api`,默认暴露端口 `5001`
@@ -160,7 +161,7 @@ Docker Compose 已配置内置健康检查:
```yaml ```yaml
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"] test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -174,6 +175,18 @@ healthcheck:
1. **端口是否一致**`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。 1. **端口是否一致**`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。
2. **开发 compose 的 WebUI 静态文件**`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js若仓库里没有 `static/admin``/admin` 会返回 404。可先在宿主机构建一次`./scripts/build-webui.sh` 2. **开发 compose 的 WebUI 静态文件**`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js若仓库里没有 `static/admin``/admin` 会返回 404。可先在宿主机构建一次`./scripts/build-webui.sh`
### 2.7 Zeabur 一键部署Dockerfile
仓库提供 `zeabur.yaml` 模板,可在 Zeabur 上一键部署:
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
部署要点:
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
--- ---
## 三、Vercel 部署 ## 三、Vercel 部署
@@ -341,6 +354,7 @@ No Output Directory named "public" found after the Build completed.
- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建) - **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建)
- **构建产物**:多平台二进制压缩包 + `sha256sums.txt` - **构建产物**:多平台二进制压缩包 + `sha256sums.txt`
- **容器镜像发布**:仅发布到 GHCR`ghcr.io/cjackhwang/ds2api`
| 平台 | 架构 | 文件格式 | | 平台 | 架构 | 文件格式 |
| --- | --- | --- | | --- | --- | --- |
@@ -378,6 +392,16 @@ cp config.example.json config.json
2. 等待 Actions 工作流 `Release Artifacts` 完成 2. 等待 Actions 工作流 `Release Artifacts` 完成
3. 在 Release 的 Assets 下载对应平台压缩包 3. 在 Release 的 Assets 下载对应平台压缩包
### 拉取 GHCR 镜像(可选)
```bash
# latest
docker pull ghcr.io/cjackhwang/ds2api:latest
# 指定版本(示例)
docker pull ghcr.io/cjackhwang/ds2api:v2.1.2
```
--- ---
## 五、反向代理Nginx ## 五、反向代理Nginx

View File

@@ -15,12 +15,44 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/ds2api ./cmd/ds2api RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/ds2api ./cmd/ds2api
FROM debian:bookworm-slim FROM busybox:1.36.1-musl AS busybox-tools
FROM debian:bookworm-slim AS runtime-base
WORKDIR /app 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 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
EXPOSE 5001
CMD ["/usr/local/bin/ds2api"]
FROM runtime-base AS runtime-from-source
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api 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/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=go-builder /app/config.example.json /app/config.example.json
COPY --from=webui-builder /app/static/admin /app/static/admin COPY --from=webui-builder /app/static/admin /app/static/admin
EXPOSE 5001
CMD ["/usr/local/bin/ds2api"] FROM busybox-tools AS dist-extract
ARG TARGETARCH
COPY dist/docker-input/linux_amd64.tar.gz /tmp/ds2api_linux_amd64.tar.gz
COPY dist/docker-input/linux_arm64.tar.gz /tmp/ds2api_linux_arm64.tar.gz
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) ARCHIVE="/tmp/ds2api_linux_amd64.tar.gz" ;; \
arm64) ARCHIVE="/tmp/ds2api_linux_arm64.tar.gz" ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
tar -xzf "${ARCHIVE}" -C /tmp; \
PKG_DIR="$(find /tmp -maxdepth 1 -type d -name "ds2api_*_linux_${TARGETARCH}" | head -n1)"; \
test -n "${PKG_DIR}"; \
mkdir -p /out/static; \
cp "${PKG_DIR}/ds2api" /out/ds2api; \
cp "${PKG_DIR}/sha3_wasm_bg.7b9ca65ddd.wasm" /out/sha3_wasm_bg.7b9ca65ddd.wasm; \
cp "${PKG_DIR}/config.example.json" /out/config.example.json; \
cp -R "${PKG_DIR}/static/admin" /out/static/admin
FROM runtime-base AS runtime-from-dist
COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api
COPY --from=dist-extract /out/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
COPY --from=dist-extract /out/config.example.json /app/config.example.json
COPY --from=dist-extract /out/static/admin /app/static/admin
FROM runtime-from-source AS final

View File

@@ -1,3 +1,7 @@
<p align="center">
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API # DS2API
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
@@ -5,6 +9,7 @@
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
语言 / Language: [中文](README.MD) | [English](README.en.md) 语言 / Language: [中文](README.MD) | [English](README.en.md)
@@ -162,6 +167,12 @@ docker-compose logs -f
更新镜像:`docker-compose up -d --build` 更新镜像:`docker-compose up -d --build`
#### Zeabur 一键部署Dockerfile
1. 点击上方 “Deploy on Zeabur” 按钮,一键部署。
2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。
3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。
### 方式三Vercel 部署 ### 方式三Vercel 部署
1. Fork 仓库到自己的 GitHub 1. Fork 仓库到自己的 GitHub
@@ -462,6 +473,7 @@ npm ci --prefix webui && npm run build --prefix webui
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发) - **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`+ `sha256sums.txt` - **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`+ `sha256sums.txt`
- **容器镜像发布**:仅推送到 GHCR`ghcr.io/cjackhwang/ds2api`
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件、配置示例、README、LICENSE - **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件、配置示例、README、LICENSE
## 免责声明 ## 免责声明

View File

@@ -1,3 +1,7 @@
<p align="center">
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
</p>
# DS2API # DS2API
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
@@ -5,6 +9,7 @@
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
Language: [中文](README.MD) | [English](README.en.md) Language: [中文](README.MD) | [English](README.en.md)
@@ -162,6 +167,12 @@ docker-compose logs -f
Rebuild after updates: `docker-compose up -d --build` Rebuild after updates: `docker-compose up -d --build`
#### Zeabur One-Click (Dockerfile)
1. Click the “Deploy on Zeabur” button above to deploy.
2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions.
3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`).
### Option 3: Vercel ### Option 3: Vercel
1. Fork this repo to your GitHub account 1. Fork this repo to your GitHub account
@@ -462,6 +473,7 @@ Workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds) - **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt` - **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file, config template, README, LICENSE - **Each archive includes**: `ds2api` executable, `static/admin`, WASM file, config template, README, LICENSE
## Disclaimer ## Disclaimer

63
assets/ds2api-icon.svg Normal file
View File

@@ -0,0 +1,63 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DS2API icon">
<defs>
<linearGradient id="bg" x1="96" y1="96" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#06162D" />
<stop offset="0.6" stop-color="#0A3A6A" />
<stop offset="1" stop-color="#00B4D8" />
</linearGradient>
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(256 180) rotate(90) scale(260)">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<linearGradient id="whale" x1="180" y1="140" x2="360" y2="360" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#EAF7FF" />
<stop offset="1" stop-color="#BDEBFF" />
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#bg)" />
<circle cx="256" cy="256" r="240" fill="url(#glow)" />
<circle cx="256" cy="256" r="240" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="8" />
<!-- subtle waves -->
<path d="M104 338 C156 308 204 366 256 334 C308 302 356 360 408 330" stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="12" stroke-linecap="round" />
<path d="M124 372 C174 344 212 396 256 372 C300 348 338 396 388 368" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="10" stroke-linecap="round" />
<!-- whale tail (DeepSeek-inspired element, original design) -->
<path
d="M256 162
C228 124 184 118 156 146
C132 170 138 206 162 230
C190 262 230 252 252 220
C254 218 255 216 256 214
C257 216 258 218 260 220
C282 252 322 262 350 230
C374 206 380 170 356 146
C328 118 284 124 256 162 Z"
fill="url(#whale)"
/>
<rect x="236" y="214" width="40" height="168" rx="20" fill="url(#whale)" />
<!-- API nodes -->
<g opacity="0.55" stroke="#FFFFFF" stroke-opacity="0.35" stroke-width="6" stroke-linecap="round">
<path d="M156 236 L208 206" />
<path d="M356 236 L304 206" />
<path d="M208 206 L232 172" />
<circle cx="156" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="208" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="232" cy="172" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="304" cy="206" r="10" fill="#FFFFFF" fill-opacity="0.28" />
<circle cx="356" cy="236" r="10" fill="#FFFFFF" fill-opacity="0.28" />
</g>
<!-- tiny sparkle -->
<path
d="M378 164
C372 170 366 174 358 176
C366 178 372 182 378 188
C380 180 384 176 392 176
C384 174 380 170 378 164 Z"
fill="#FFFFFF"
fill-opacity="0.32"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -11,7 +11,7 @@ services:
- HOST=0.0.0.0 - HOST=0.0.0.0
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"] test: ["CMD", "/usr/local/bin/busybox", "wget", "-qO-", "http://localhost:${PORT:-5001}/healthz"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -183,6 +183,66 @@ func TestHandleClaudeStreamRealtimeToolSafety(t *testing.T) {
} }
} }
func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "search" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block from thinking fallback, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"normal answer"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String())
}
}
foundEndTurn := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "end_turn" {
foundEndTurn = true
break
}
}
if !foundEndTurn {
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) { func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeClaudeSSEHTTPResponse( resp := makeClaudeSSEHTTPResponse(

View File

@@ -141,6 +141,34 @@ func TestBuildClaudeToolPromptMultipleTools(t *testing.T) {
} }
} }
func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) {
tools := []any{
map[string]any{
"type": "function",
"function": map[string]any{
"name": "search",
"description": "Search via function tool",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
},
},
},
}
prompt := buildClaudeToolPrompt(tools)
if !containsStr(prompt, "Tool: search") {
t.Fatalf("expected OpenAI-style function tool name in prompt, got: %q", prompt)
}
if !containsStr(prompt, "Search via function tool") {
t.Fatalf("expected OpenAI-style function tool description in prompt, got: %q", prompt)
}
if !containsStr(prompt, "\"q\"") {
t.Fatalf("expected parameters schema serialized in prompt, got: %q", prompt)
}
}
func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) { func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) {
tools := []any{"not a map"} tools := []any{"not a map"}
prompt := buildClaudeToolPrompt(tools) prompt := buildClaudeToolPrompt(tools)
@@ -237,6 +265,21 @@ func TestExtractClaudeToolNamesNil(t *testing.T) {
} }
} }
func TestExtractClaudeToolNamesSupportsOpenAIStyleFunctionTool(t *testing.T) {
tools := []any{
map[string]any{
"type": "function",
"function": map[string]any{
"name": "search",
},
},
}
names := extractClaudeToolNames(tools)
if len(names) != 1 || names[0] != "search" {
t.Fatalf("expected [search], got %v", names)
}
}
// ─── toMessageMaps ─────────────────────────────────────────────────── // ─── toMessageMaps ───────────────────────────────────────────────────
func TestToMessageMapsNormal(t *testing.T) { func TestToMessageMapsNormal(t *testing.T) {

View File

@@ -46,9 +46,8 @@ func buildClaudeToolPrompt(tools []any) string {
if !ok { if !ok {
continue continue
} }
name, _ := m["name"].(string) name, desc, schemaObj := extractClaudeToolMeta(m)
desc, _ := m["description"].(string) schema, _ := json.Marshal(schemaObj)
schema, _ := json.Marshal(m["input_schema"])
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
} }
parts = append(parts, parts = append(parts,
@@ -98,13 +97,43 @@ func extractClaudeToolNames(tools []any) []string {
if !ok { if !ok {
continue continue
} }
if name, ok := m["name"].(string); ok && name != "" { name, _, _ := extractClaudeToolMeta(m)
if name != "" {
out = append(out, name) out = append(out, name)
} }
} }
return out return out
} }
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
schemaObj := m["input_schema"]
if schemaObj == nil {
schemaObj = m["parameters"]
}
if fn, ok := m["function"].(map[string]any); ok {
if strings.TrimSpace(name) == "" {
name, _ = fn["name"].(string)
}
if strings.TrimSpace(desc) == "" {
desc, _ = fn["description"].(string)
}
if schemaObj == nil {
if v, ok := fn["input_schema"]; ok {
schemaObj = v
}
}
if schemaObj == nil {
if v, ok := fn["parameters"]; ok {
schemaObj = v
}
}
}
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
}
func toMessageMaps(v any) []map[string]any { func toMessageMaps(v any) []map[string]any {
arr, ok := v.([]any) arr, ok := v.([]any)
if !ok { if !ok {

View File

@@ -46,6 +46,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
if s.bufferToolContent { if s.bufferToolContent {
detected := util.ParseToolCalls(finalText, s.toolNames) detected := util.ParseToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 { if len(detected) > 0 {
stopReason = "tool_use" stopReason = "tool_use"
for i, tc := range detected { for i, tc := range detected {

View File

@@ -375,7 +375,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
} }
} }
func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) { func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`, `data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -393,8 +393,34 @@ func TestHandleStreamUnknownToolNotIntercepted(t *testing.T) {
if streamHasToolCallsDelta(frames) { if streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String()) t.Fatalf("did not expect tool_calls delta for unknown schema name, body=%s", rec.Body.String())
} }
if !streamHasRawToolJSONContent(frames) { if streamHasRawToolJSONContent(frames) {
t.Fatalf("expected raw tool_calls json to remain in content for unknown schema name: %s", rec.Body.String()) t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid5b", "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("did not expect tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
} }
if streamFinishReason(frames) != "stop" { if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String()) t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())

View File

@@ -200,9 +200,14 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
if insideCodeFence(state.recentTextTail + prefixPart) { if insideCodeFence(state.recentTextTail + prefixPart) {
return captured, nil, "", true return captured, nil, "", true
} }
parsed := util.ParseStandaloneToolCalls(obj, toolNames) parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed) == 0 { if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
// Parsed as tool-call payload but rejected by schema/policy:
// consume it to avoid leaking raw tool_calls JSON to user content.
return prefixPart, nil, suffixPart, true
}
return captured, nil, "", true return captured, nil, "", true
} }
return prefixPart, parsed, suffixPart, true return prefixPart, parsed.Calls, suffixPart, true
} }

View File

@@ -16,6 +16,7 @@ type ConfigStore interface {
Accounts() []config.Account Accounts() []config.Account
FindAccount(identifier string) (config.Account, bool) FindAccount(identifier string) (config.Account, bool)
UpdateAccountToken(identifier, token string) error UpdateAccountToken(identifier, token string) error
UpdateAccountTestStatus(identifier, status string) error
Update(mutator func(*config.Config) error) error Update(mutator func(*config.Config) error) error
ExportJSONAndBase64() (string, string, error) ExportJSONAndBase64() (string, string, error)
IsEnvBacked() bool IsEnvBacked() bool

View File

@@ -56,6 +56,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"has_password": acc.Password != "", "has_password": acc.Password != "",
"has_token": token != "", "has_token": token != "",
"token_preview": preview, "token_preview": preview,
"test_status": acc.TestStatus,
}) })
} }
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})

View File

@@ -88,7 +88,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now() start := time.Now()
result := map[string]any{"account": acc.Identifier(), "success": false, "response_time": 0, "message": "", "model": model} identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
status = "ok"
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
}()
token := strings.TrimSpace(acc.Token) token := strings.TrimSpace(acc.Token)
if token == "" { if token == "" {
newToken, err := h.DS.Login(ctx, acc) newToken, err := h.DS.Login(ctx, acc)

View File

@@ -18,10 +18,11 @@ type Config struct {
} }
type Account struct { type Account struct {
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"` Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
TestStatus string `json:"test_status,omitempty"`
} }
type CompatConfig struct { type CompatConfig struct {

View File

@@ -97,6 +97,18 @@ func (s *Store) FindAccount(identifier string) (Account, bool) {
return Account{}, false return Account{}, false
} }
func (s *Store) UpdateAccountTestStatus(identifier, status string) error {
identifier = strings.TrimSpace(identifier)
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].TestStatus = status
return s.saveLocked()
}
func (s *Store) UpdateAccountToken(identifier, token string) error { func (s *Store) UpdateAccountToken(identifier, token string) error {
identifier = strings.TrimSpace(identifier) identifier = strings.TrimSpace(identifier)
s.mu.Lock() s.mu.Lock()

View File

@@ -9,6 +9,9 @@ import (
func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseToolCalls(finalText, toolNames) detected := util.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
}
content := make([]map[string]any, 0, 4) content := make([]map[string]any, 0, 4)
if finalThinking != "" { if finalThinking != "" {
content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking}) content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking})

View File

@@ -0,0 +1,62 @@
package claude
import "testing"
func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T) {
resp := BuildMessageResponse(
"msg_1",
"claude-sonnet-4-5",
[]any{map[string]any{"role": "user", "content": "hi"}},
`{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`,
"",
[]string{"search"},
)
if resp["stop_reason"] != "tool_use" {
t.Fatalf("expected stop_reason=tool_use, got=%#v", resp["stop_reason"])
}
content, _ := resp["content"].([]map[string]any)
if len(content) < 2 {
t.Fatalf("expected thinking + tool_use content blocks, got=%#v", resp["content"])
}
last := content[len(content)-1]
if last["type"] != "tool_use" {
t.Fatalf("expected last content block tool_use, got=%#v", last["type"])
}
if last["name"] != "search" {
t.Fatalf("expected tool name search, got=%#v", last["name"])
}
}
func TestBuildMessageResponseSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
resp := BuildMessageResponse(
"msg_1",
"claude-sonnet-4-5",
[]any{map[string]any{"role": "user", "content": "hi"}},
`{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`,
"normal answer",
[]string{"search"},
)
if resp["stop_reason"] != "end_turn" {
t.Fatalf("expected stop_reason=end_turn, got=%#v", resp["stop_reason"])
}
content, _ := resp["content"].([]map[string]any)
foundText := false
foundTool := false
for _, block := range content {
if block["type"] == "text" && block["text"] == "normal answer" {
foundText = true
}
if block["type"] == "tool_use" {
foundTool = true
}
}
if !foundText {
t.Fatalf("expected text block with finalText, got=%#v", resp["content"])
}
if foundTool {
t.Fatalf("unexpected tool_use block when finalText exists, got=%#v", resp["content"])
}
}

View File

@@ -263,14 +263,6 @@ function filterToolCalls(parsed, toolNames) {
} }
out.push({ name: tc.name, input: tc.input || {} }); out.push({ name: tc.name, input: tc.input || {} });
} }
if (out.length === 0 && parsed.length > 0) {
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
out.push({ name: tc.name, input: tc.input || {} });
}
}
return out; return out;
} }

View File

@@ -205,8 +205,17 @@ function consumeToolCapture(state, toolNames) {
suffix: '', suffix: '',
}; };
} }
const rawParsed = parseStandaloneToolCalls(captured.slice(start, obj.end), []);
const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames); const parsed = parseStandaloneToolCalls(captured.slice(start, obj.end), toolNames);
if (parsed.length === 0) { if (parsed.length === 0) {
if (rawParsed.length > 0 && Array.isArray(toolNames) && toolNames.length > 0) {
return {
ready: true,
prefix: prefixPart,
calls: [],
suffix: suffixPart,
};
}
if (state.toolNameSent) { if (state.toolNameSent) {
return { return {
ready: true, ready: true,

View File

@@ -89,8 +89,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) { func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) {
allowed := map[string]struct{}{} allowed := map[string]struct{}{}
allowedCanonical := map[string]string{}
for _, name := range availableToolNames { for _, name := range availableToolNames {
allowed[name] = struct{}{} trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
allowed[trimmed] = struct{}{}
lower := strings.ToLower(trimmed)
if _, exists := allowedCanonical[lower]; !exists {
allowedCanonical[lower] = trimmed
}
} }
if len(allowed) == 0 { if len(allowed) == 0 {
rejectedSet := map[string]struct{}{} rejectedSet := map[string]struct{}{}
@@ -112,10 +121,17 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
if tc.Name == "" { if tc.Name == "" {
continue continue
} }
if _, ok := allowed[tc.Name]; !ok { matchedName := ""
if _, ok := allowed[tc.Name]; ok {
matchedName = tc.Name
} else if canonical, ok := allowedCanonical[strings.ToLower(tc.Name)]; ok {
matchedName = canonical
}
if matchedName == "" {
rejectedSet[tc.Name] = struct{}{} rejectedSet[tc.Name] = struct{}{}
continue continue
} }
tc.Name = matchedName
if tc.Input == nil { if tc.Input == nil {
tc.Input = map[string]any{} tc.Input = map[string]any{}
} }

View File

@@ -46,6 +46,17 @@ func TestParseToolCallsRejectsUnknownToolName(t *testing.T) {
} }
} }
func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) {
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
}
}
func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) { func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}` text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
res := ParseToolCallsDetailed(text, []string{"search"}) res := ParseToolCallsDetailed(text, []string{"search"})

View File

@@ -52,11 +52,19 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', ()
]); ]);
}); });
test('parseToolCalls still intercepts unknown schema names to avoid leaks', () => { test('parseToolCalls drops unknown schema names when toolNames is provided', () => {
const payload = JSON.stringify({ const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }], tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
}); });
const calls = parseToolCalls(payload, ['search']); const calls = parseToolCalls(payload, ['search']);
assert.equal(calls.length, 0);
});
test('parseToolCalls keeps unknown names when toolNames is empty', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
});
const calls = parseToolCalls(payload, []);
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'not_in_schema'); assert.equal(calls[0].name, 'not_in_schema');
}); });
@@ -144,6 +152,20 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
}); });
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
const events = runSieve(
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
['read_file'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
assert.equal(hasToolCall, false);
assert.equal(hasToolDelta, false);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
assert.equal(leakedText.includes('后置正文G。'), true);
});
test('sieve emits incremental tool_call_deltas for split arguments payload', () => { test('sieve emits incremental tool_call_deltas for split arguments payload', () => {
const state = createToolSieveState(); const state = createToolSieveState();
const first = processToolSieveChunk( const first = processToolSieveChunk(

View File

@@ -5,4 +5,18 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
./tests/scripts/check-node-split-syntax.sh ./tests/scripts/check-node-split-syntax.sh
node --test tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js "$@"
# Keep Node's file-level test scheduling serial to avoid intermittent cross-file
# interference when multiple suites import mutable module singletons.
NODE_TEST_LOG="$(mktemp)"
cleanup() {
rm -f "$NODE_TEST_LOG"
}
trap cleanup EXIT
if ! node --test --test-concurrency=1 tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js "$@" 2>&1 | tee "$NODE_TEST_LOG"; then
echo
echo "[run-unit-node] Node tests failed. 失败摘要如下:"
rg -n "^(not ok|# fail)|ERR_TEST_FAILURE" "$NODE_TEST_LOG" || true
exit 1
fi

View File

@@ -17,10 +17,12 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
setKeysExpanded, setKeysExpanded,
accounts, accounts,
page, page,
pageSize,
totalPages, totalPages,
totalAccounts, totalAccounts,
loadingAccounts, loadingAccounts,
fetchAccounts, fetchAccounts,
changePageSize,
resolveAccountIdentifier, resolveAccountIdentifier,
} = useAccountsData({ apiFetch }) } = useAccountsData({ apiFetch })
@@ -79,6 +81,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
batchProgress={batchProgress} batchProgress={batchProgress}
totalAccounts={totalAccounts} totalAccounts={totalAccounts}
page={page} page={page}
pageSize={pageSize}
totalPages={totalPages} totalPages={totalPages}
resolveAccountIdentifier={resolveAccountIdentifier} resolveAccountIdentifier={resolveAccountIdentifier}
onTestAll={testAllAccounts} onTestAll={testAllAccounts}
@@ -87,6 +90,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
onDeleteAccount={deleteAccount} onDeleteAccount={deleteAccount}
onPrevPage={() => fetchAccounts(page - 1)} onPrevPage={() => fetchAccounts(page - 1)}
onNextPage={() => fetchAccounts(page + 1)} onNextPage={() => fetchAccounts(page + 1)}
onPageSizeChange={changePageSize}
/> />
<AddKeyModal <AddKeyModal

View File

@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight, Play, Plus, Trash2 } from 'lucide-react' import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
export default function AccountsTable({ export default function AccountsTable({
@@ -10,6 +11,7 @@ export default function AccountsTable({
batchProgress, batchProgress,
totalAccounts, totalAccounts,
page, page,
pageSize,
totalPages, totalPages,
resolveAccountIdentifier, resolveAccountIdentifier,
onTestAll, onTestAll,
@@ -18,7 +20,16 @@ export default function AccountsTable({
onDeleteAccount, onDeleteAccount,
onPrevPage, onPrevPage,
onNextPage, onNextPage,
onPageSizeChange,
}) { }) {
const [copiedId, setCopiedId] = useState(null)
const copyId = (id) => {
navigator.clipboard.writeText(id).then(() => {
setCopiedId(id)
setTimeout(() => setCopiedId(null), 1500)
})
}
return ( return (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm"> <div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -83,12 +94,23 @@ export default function AccountsTable({
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className={clsx( <div className={clsx(
"w-2 h-2 rounded-full shrink-0", "w-2 h-2 rounded-full shrink-0",
acc.has_token ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : "bg-amber-500" acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
"bg-amber-500"
)} /> )} />
<div className="min-w-0"> <div className="min-w-0">
<div className="font-medium truncate">{id || '-'}</div> <div
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
onClick={() => copyId(id)}
>
<span className="truncate">{id || '-'}</span>
{copiedId === id
? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5"> <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span> <span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && ( {acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]"> <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{acc.token_preview} {acc.token_preview}
@@ -122,8 +144,19 @@ export default function AccountsTable({
{totalPages > 1 && ( {totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between"> <div className="p-4 border-t border-border flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div className="flex items-center gap-3">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} <div className="text-sm text-muted-foreground">
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
</div>
<select
value={pageSize}
onChange={e => onPageSizeChange(Number(e.target.value))}
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
>
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button

View File

@@ -6,7 +6,7 @@ export function useAccountsData({ apiFetch }) {
const [accounts, setAccounts] = useState([]) const [accounts, setAccounts] = useState([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [totalPages, setTotalPages] = useState(1) const [totalPages, setTotalPages] = useState(1)
const [totalAccounts, setTotalAccounts] = useState(0) const [totalAccounts, setTotalAccounts] = useState(0)
const [loadingAccounts, setLoadingAccounts] = useState(false) const [loadingAccounts, setLoadingAccounts] = useState(false)
@@ -16,10 +16,10 @@ export function useAccountsData({ apiFetch }) {
return String(acc.identifier || acc.email || acc.mobile || '').trim() return String(acc.identifier || acc.email || acc.mobile || '').trim()
} }
const fetchAccounts = async (targetPage = page) => { const fetchAccounts = async (targetPage = page, targetPageSize = pageSize) => {
setLoadingAccounts(true) setLoadingAccounts(true)
try { try {
const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${pageSize}`) const res = await apiFetch(`/admin/accounts?page=${targetPage}&page_size=${targetPageSize}`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setAccounts(data.items || []) setAccounts(data.items || [])
@@ -34,6 +34,11 @@ export function useAccountsData({ apiFetch }) {
} }
} }
const changePageSize = (newSize) => {
setPageSize(newSize)
fetchAccounts(1, newSize)
}
const fetchQueueStatus = async () => { const fetchQueueStatus = async () => {
try { try {
const res = await apiFetch('/admin/queue/status') const res = await apiFetch('/admin/queue/status')
@@ -59,10 +64,12 @@ export function useAccountsData({ apiFetch }) {
setKeysExpanded, setKeysExpanded,
accounts, accounts,
page, page,
pageSize,
totalPages, totalPages,
totalAccounts, totalAccounts,
loadingAccounts, loadingAccounts,
fetchAccounts, fetchAccounts,
changePageSize,
resolveAccountIdentifier, resolveAccountIdentifier,
} }
} }

View File

@@ -113,6 +113,7 @@
"testingAllAccounts": "Testing all accounts...", "testingAllAccounts": "Testing all accounts...",
"sessionActive": "Session active", "sessionActive": "Session active",
"reauthRequired": "Re-auth required", "reauthRequired": "Re-auth required",
"testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.", "noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key", "modalAddKeyTitle": "Add API key",
"newKeyLabel": "New key value", "newKeyLabel": "New key value",

View File

@@ -113,6 +113,7 @@
"testingAllAccounts": "正在测试所有账号...", "testingAllAccounts": "正在测试所有账号...",
"sessionActive": "已建立会话", "sessionActive": "已建立会话",
"reauthRequired": "需重新登录", "reauthRequired": "需重新登录",
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号", "noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥", "modalAddKeyTitle": "添加 API 密钥",
"newKeyLabel": "新密钥值", "newKeyLabel": "新密钥值",

60
zeabur.yaml Normal file
View File

@@ -0,0 +1,60 @@
# yaml-language-server: $schema=https://schema.zeabur.app/template.json
apiVersion: zeabur.com/v1
kind: Template
metadata:
name: DS2API
spec:
description: DeepSeek Web 对话转 OpenAI/Claude/Gemini 兼容 APIGo 实现,含 WebUI
tags:
- DeepSeek
- API
- Go
readme: |-
# DS2API (Zeabur)
## After deployment
- Admin panel: `/admin`
- Health check: `/healthz`
- Config is persisted at `/data/config.json` (mounted volume)
## First-time setup
1. Open your service URL, then visit `/admin`
2. Login with `DS2API_ADMIN_KEY` (shown in Zeabur env/instructions)
3. Import / edit config in Admin UI (saved to `/data/config.json`)
services:
- name: ds2api
template: GIT
spec:
source:
source: GITHUB
repo: 1139136822
branch: main
rootDirectory: /
ports:
- id: web
port: 5001
type: HTTP
volumes:
- id: data
dir: /data
env:
PORT:
default: "5001"
LOG_LEVEL:
default: "INFO"
DS2API_ADMIN_KEY:
default: ${PASSWORD}
expose: true
DS2API_CONFIG_PATH:
default: /data/config.json
instructions:
- title: Admin panel
content: Visit `/admin` on your service URL.
- title: DS2API admin key
content: ${DS2API_ADMIN_KEY}
healthCheck:
type: HTTP
port: web
http:
path: /healthz