mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 00:15:28 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f91da403f | ||
|
|
89e5ad24b9 | ||
|
|
3f106ac112 | ||
|
|
f6f6a651fd | ||
|
|
f60a3ea501 | ||
|
|
3f09d60cdc | ||
|
|
d3b5493d2e | ||
|
|
255feb2e65 | ||
|
|
4b73315df0 | ||
|
|
a086e0cfa1 | ||
|
|
f3bc022a36 | ||
|
|
b7cb7ef0c1 | ||
|
|
267420a46a | ||
|
|
3c66ab958a | ||
|
|
cf2f79b6f4 | ||
|
|
ab6e817c8e | ||
|
|
9ae4630a3b | ||
|
|
d1b8537cfb | ||
|
|
d32b4481da | ||
|
|
52a04ac575 | ||
|
|
0d3d535c08 | ||
|
|
224462018a | ||
|
|
35e89230fd | ||
|
|
9a57af6092 | ||
|
|
2e1bd8a481 | ||
|
|
1e678ecc1a |
@@ -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/
|
||||||
|
|||||||
90
.github/workflows/release-artifacts.yml
vendored
90
.github/workflows/release-artifacts.yml
vendored
@@ -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
|
||||||
|
|||||||
34
DEPLOY.en.md
34
DEPLOY.en.md
@@ -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:
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|||||||
34
DEPLOY.md
34
DEPLOY.md
@@ -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 上一键部署:
|
||||||
|
|
||||||
|
[](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)
|
||||||
|
|||||||
40
Dockerfile
40
Dockerfile
@@ -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
|
||||||
|
|||||||
12
README.MD
12
README.MD
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
|
||||||
|
</p>
|
||||||
|
|
||||||
# DS2API
|
# DS2API
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -5,6 +9,7 @@
|
|||||||

|

|
||||||
[](https://github.com/CJackHwang/ds2api/releases)
|
[](https://github.com/CJackHwang/ds2api/releases)
|
||||||
[](DEPLOY.md)
|
[](DEPLOY.md)
|
||||||
|
[](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
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|||||||
12
README.en.md
12
README.en.md
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="assets/ds2api-icon.svg" width="128" height="128" alt="DS2API icon" />
|
||||||
|
</p>
|
||||||
|
|
||||||
# DS2API
|
# DS2API
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@@ -5,6 +9,7 @@
|
|||||||

|

|
||||||
[](https://github.com/CJackHwang/ds2api/releases)
|
[](https://github.com/CJackHwang/ds2api/releases)
|
||||||
[](DEPLOY.en.md)
|
[](DEPLOY.en.md)
|
||||||
|
[](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
63
assets/ds2api-icon.svg
Normal 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 |
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
62
internal/format/claude/render_test.go
Normal file
62
internal/format/claude/render_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
"testingAllAccounts": "正在测试所有账号...",
|
"testingAllAccounts": "正在测试所有账号...",
|
||||||
"sessionActive": "已建立会话",
|
"sessionActive": "已建立会话",
|
||||||
"reauthRequired": "需重新登录",
|
"reauthRequired": "需重新登录",
|
||||||
|
"testStatusFailed": "上次测试失败",
|
||||||
"noAccounts": "未找到任何账号",
|
"noAccounts": "未找到任何账号",
|
||||||
"modalAddKeyTitle": "添加 API 密钥",
|
"modalAddKeyTitle": "添加 API 密钥",
|
||||||
"newKeyLabel": "新密钥值",
|
"newKeyLabel": "新密钥值",
|
||||||
|
|||||||
60
zeabur.yaml
Normal file
60
zeabur.yaml
Normal 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 兼容 API(Go 实现,含 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
|
||||||
Reference in New Issue
Block a user