Compare commits

..

76 Commits

Author SHA1 Message Date
CJACK.
586d31e556 Merge pull request #151 from CJackHwang/dev
Merge pull request #149 from CJackHwang/codex/fix-tool-miscall-during-complex-json-test

Ignore tool_call payloads inside fenced code blocks and chat envelopes; stream-aware code-fence tracking
2026-03-22 16:51:17 +08:00
CJACK.
b0a09dfab0 Merge pull request #149 from CJackHwang/codex/fix-tool-miscall-during-complex-json-test
Ignore tool_call payloads inside fenced code blocks and chat envelopes; stream-aware code-fence tracking
2026-03-22 16:50:44 +08:00
CJACK.
58f753d0c0 Merge pull request #150 from CJackHwang/codex/fix-markup-bypass-in-tool-call-parsing
Do not promote fenced code examples to tool calls and centralize tool-keyword detection
2026-03-22 16:36:39 +08:00
CJACK.
2e0586d060 Merge branch 'codex/fix-tool-miscall-during-complex-json-test' into codex/fix-markup-bypass-in-tool-call-parsing 2026-03-22 16:32:43 +08:00
CJACK.
1676c8e4f2 Add backward-compatible aliases for renamed fenced-example tests 2026-03-22 16:25:03 +08:00
CJACK.
add13366d2 Split parse syntax markers to shared keyword module 2026-03-22 15:55:47 +08:00
CJACK.
d5a23191f2 Refactor stream sieve keyword scanning into shared helper 2026-03-22 15:55:38 +08:00
CJACK.
d2d4e39983 Fix refactor line gate for stream tool sieve helper 2026-03-22 15:28:51 +08:00
CJACK.
6e0dca3b30 Update VERSION 2026-03-22 15:16:29 +08:00
CJACK.
b108a7915a Support nested fenced blocks in stream fence tracking 2026-03-22 15:12:55 +08:00
CJACK.
2caabd8ce6 Add files via upload 2026-03-22 14:18:08 +08:00
CJACK.
c4a73e871a Merge pull request #148 from CJackHwang/dev
Merge pull request #147 from CJackHwang/codex/fix-tool-call-history-retrieval

Preserve tool call/result roundtrip and raw payloads across Claude, Gemini and OpenAI adapters
2026-03-22 13:43:26 +08:00
CJACK.
6802a3d53e Fix Claude tool block normalization and tool_result fidelity 2026-03-22 13:42:01 +08:00
CJACK.
e828006cb0 Merge pull request #147 from CJackHwang/codex/fix-tool-call-history-retrieval
Preserve tool call/result roundtrip and raw payloads across Claude, Gemini and OpenAI adapters
2026-03-22 13:06:23 +08:00
CJACK.
a6499cbece Split Claude sanitize helpers to satisfy refactor line gate 2026-03-22 13:05:41 +08:00
CJACK.
a504905626 Fix Claude/Gemini prompt flattening for tool history and binary parts 2026-03-22 12:47:00 +08:00
CJACK.
59bf78d2c4 Unify adapter message normalization across Claude and Gemini 2026-03-22 12:07:58 +08:00
CJACK.
25b3292497 Merge pull request #146 from CJackHwang/dev
Merge pull request #145 from CJackHwang/codex/determine-which-pr-fixes-json-leak-issue

Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy

Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 11:05:54 +08:00
CJACK.
6cf4f0528c Merge pull request #145 from CJackHwang/codex/determine-which-pr-fixes-json-leak-issue
Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy

Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:59:31 +08:00
CJACK.
d8f8dcb704 Merge pull request #144 from CJackHwang/codex/refactor-codebase-to-remove-redundancy
Refactor tool-sieve and response streaming, remove unused helpers and UI wrappers
2026-03-22 10:39:36 +08:00
CJACK.
455489ffeb ci: upgrade GitHub Actions Node runtime to 24 2026-03-22 10:38:18 +08:00
CJACK.
5031ae0e6f ci: align refactor line gate with removed files 2026-03-22 10:38:08 +08:00
CJACK.
3fccec0e22 test: remove unused asFloat helper 2026-03-22 10:24:11 +08:00
CJACK.
00d38f1187 fix: parse claude tool_use function/parameter format 2026-03-22 09:58:29 +08:00
CJACK.
fe0f3d2c17 fix: strip empty json fences from sanitized stream text 2026-03-22 09:29:21 +08:00
CJACK.
f67cbfad35 fix: stop instructing fenced JSON for tool calls 2026-03-22 09:25:01 +08:00
CJACK.
11f66db87d Merge pull request #142 from CJackHwang/dev
Merge pull request #141 from CJackHwang/codex/investigate-json-leakage-in-vercel-deployment-rh84s1

Fix raw tool-call JSON leaks when feature_match mode is off
2026-03-22 08:55:29 +08:00
CJACK.
9afc533153 Merge pull request #141 from CJackHwang/codex/investigate-json-leakage-in-vercel-deployment-rh84s1
Fix raw tool-call JSON leaks when feature_match mode is off
2026-03-22 08:38:18 +08:00
CJACK.
6a39543288 fix tool-call json leaks when feature_match is disabled 2026-03-22 08:29:01 +08:00
CJACK.
7131b06e26 Merge pull request #138 from CJackHwang/dev
Merge pull request #135 from CJackHwang/codex/add-global-token-refresh-logic

Sanitize leaked tool-history markers, simplify normalization, and add managed token refresh
2026-03-22 01:27:27 +08:00
CJACK.
8fa1f998aa Merge pull request #139 from CJackHwang/codex/fix-issues-from-codex-review
[Follow-up] Preserve empty tool completion turns in OpenAI prompt normalization
2026-03-22 01:26:43 +08:00
CJACK.
f8936887d0 fix(openai): preserve empty tool completion turns 2026-03-22 01:19:17 +08:00
CJACK.
db89744055 Merge branch 'main' into dev 2026-03-22 01:07:14 +08:00
CJACK.
65312fc573 Merge pull request #135 from CJackHwang/codex/add-global-token-refresh-logic
Sanitize leaked tool-history markers, simplify normalization, and add managed token refresh
2026-03-22 01:05:10 +08:00
CJACK.
661d753fd3 Merge pull request #137 from CJackHwang/codex/optimize-configuration-file-management
Make account `test_status` runtime-only (in-memory cache)
2026-03-22 01:04:42 +08:00
CJACK.
7ca3f141c6 Pass refactor line gate for tool sieve files 2026-03-22 01:04:01 +08:00
CJACK.
d530d25793 Expand history-sanitize boundary coverage for stream chunks 2026-03-22 00:57:13 +08:00
CJACK.
990cdcf02d refactor config: keep account test status runtime-only 2026-03-22 00:49:53 +08:00
CJACK.
648bb74587 Fix streaming whitespace trim and capture TOOL_RESULT_HISTORY 2026-03-22 00:44:44 +08:00
CJACK.
9e5baed061 Merge pull request #136 from CJackHwang/codex/add-file-import-and-export-for-project-config
feat(webui): add config backup download and file-based import in Settings
2026-03-22 00:31:30 +08:00
CJACK.
4884773639 feat(webui): support backup file export and import 2026-03-22 00:29:01 +08:00
CJACK.
6758514c61 chore: remove obsolete openai tool-history normalization helpers 2026-03-22 00:28:32 +08:00
CJACK.
01f33c409f Update VERSION 2026-03-21 18:04:39 +08:00
CJACK.
55f11e655a Update VERSION 2026-03-21 18:04:11 +08:00
CJACK.
2275e931f9 Merge pull request #133 from CJackHwang/dev
Merge pull request #132 from CJackHwang/codex/toolcallhistory-6t7271

Preserve code fences around standalone tool JSON and add marker-output guards
2026-03-21 17:54:56 +08:00
CJACK.
40594a44db Fix env-backed Vercel sync override and config refresh behavior 2026-03-21 17:53:44 +08:00
CJACK.
67787d9c99 Merge pull request #132 from CJackHwang/codex/toolcallhistory-6t7271
Preserve code fences around standalone tool JSON and add marker-output guards
2026-03-21 17:44:05 +08:00
CJACK.
7061094964 Fix fence-strip regression for closed code blocks before tool JSON 2026-03-21 17:39:08 +08:00
CJACK.
492c603300 Merge pull request #129 from CJackHwang/codex/optimize-vercel-deployment-sync-mechanism
Vercel sync: support env-backed config drafts, hash diffing and UI indicators
2026-03-21 17:21:42 +08:00
CJACK.
7e473dffc9 Fix Vercel sync override to avoid redacted config payloads 2026-03-21 17:19:32 +08:00
CJACK.
43a6e6712f Show UI drift marker for env draft vs Vercel config 2026-03-21 17:08:43 +08:00
CJACK.
ce1b76c90f Merge pull request #126 from CJackHwang/dev
Merge pull request #125 from CJackHwang/codex/align-documentation-with-configuration-updates

Docs: add `auto_delete.sessions`, rename `claude_model_mapping` to `claude_mapping`, and clarify config token handling
2026-03-21 15:44:28 +08:00
CJACK.
1e7e0b2ae3 Merge pull request #125 from CJackHwang/codex/align-documentation-with-configuration-updates
Docs: add `auto_delete.sessions`, rename `claude_model_mapping` to `claude_mapping`, and clarify config token handling
2026-03-21 15:34:35 +08:00
CJACK.
fd158e5ae2 Merge pull request #124 from CJackHwang/codex/fix-codex-review-issues-in-pr-#123
Preserve file-backed account tokens on startup and add regression test
2026-03-21 15:34:01 +08:00
CJACK.
95c96f7744 docs: clarify configured account token is ignored on load 2026-03-21 15:32:09 +08:00
CJACK.
e7f59fac80 Update VERSION 2026-03-21 15:22:09 +08:00
CJACK.
1bf059396f Fix file-backed token reuse at startup 2026-03-21 15:19:41 +08:00
CJACK.
696b403173 Merge pull request #123 from CJackHwang/dev
Merge pull request #122 from CJackHwang/codex/refactor-configuration-to-remove-token-support

Treat account tokens as runtime-only; remove token-only account support and always refresh tokens on admin actions
2026-03-21 15:14:17 +08:00
CJACK.
f4db2732b0 Merge pull request #122 from CJackHwang/codex/refactor-configuration-to-remove-token-support
Treat account tokens as runtime-only; remove token-only account support and always refresh tokens on admin actions
2026-03-21 15:07:19 +08:00
CJACK.
ee88a74dcf Drop legacy token-only accounts when loading config 2026-03-21 15:01:16 +08:00
CJACK.
ca08bb66b9 Add HTTP token-runtime coverage and fix gate tests for tokenless config 2026-03-21 14:27:12 +08:00
CJACK.
708fcb5beb Merge pull request #121 from jacob-sheng/fix/zeabur-build-version-fallback-zh
fix: 修复 Docker 在缺少 BUILD_VERSION 时构建失败
2026-03-21 11:17:58 +08:00
jacob-sheng
7a65d1eaa2 fix: allow Docker builds without BUILD_VERSION 2026-03-21 09:55:53 +08:00
CJACK.
6de2457743 Merge pull request #119 from CJackHwang/dev
Merge pull request #118 from CJackHwang/codex/analyze-and-fix-build-failure-for-pr-117

fix: decouple runtime-from-dist image from go-builder stage
2026-03-21 02:00:35 +08:00
CJACK.
ce44e260bf Merge pull request #118 from CJackHwang/codex/analyze-and-fix-build-failure-for-pr-117
fix: decouple runtime-from-dist image from go-builder stage
2026-03-21 01:59:52 +08:00
CJACK.
09f6537ffc fix: decouple runtime-from-dist image from go-builder stage 2026-03-21 01:32:09 +08:00
CJACK.
ab8f494fdb Merge pull request #117 from CJackHwang/dev
Merge pull request #115 from CJackHwang/codex/fix-version-detection-for-ds2api

Expose version endpoint, add version package, and inject build version into artifacts/Docker images
2026-03-21 00:51:36 +08:00
CJACK.
b56a211da9 Merge pull request #115 from CJackHwang/codex/fix-version-detection-for-ds2api
Expose version endpoint, add version package, and inject build version into artifacts/Docker images
2026-03-21 00:47:57 +08:00
CJACK.
fcce5308cb Merge pull request #116 from CJackHwang/codex/align-vercel-deployment-with-go-version-semantics
Align Vercel JS toolcall detection/format behavior with Go semantics
2026-03-21 00:43:50 +08:00
CJACK.
d27b19cc53 fix: show vercel preview commit version instead of dev 2026-03-21 00:43:09 +08:00
CJACK.
b8ff678f24 Align Vercel JS toolcall filtering with Go semantics 2026-03-21 00:23:22 +08:00
CJACK.
b24ef1282d fix: route /admin/version to api on vercel 2026-03-21 00:18:55 +08:00
CJACK.
65e0de3c82 Merge pull request #112 from CJackHwang/codex/fix-token-expiration-handling
Attempt token refresh for biz_code failures; report config writability and handle token write errors
2026-03-20 23:56:40 +08:00
CJACK.
0c2743a48c fix: align build version source with tags and VERSION fallback 2026-03-20 23:55:10 +08:00
CJACK.
dc73e8a6da Gate biz_code refresh attempts to auth-indicative failures 2026-03-20 23:54:13 +08:00
CJACK.
b8495eeeb3 surface account test config writeability and save failures 2026-03-20 23:34:29 +08:00
101 changed files with 3114 additions and 1194 deletions

View File

@@ -24,7 +24,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json

View File

@@ -32,7 +32,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json
@@ -51,6 +51,10 @@ jobs:
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
BUILD_VERSION="${TAG}"
if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then
BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')"
fi
mkdir -p dist
targets=(
@@ -73,7 +77,7 @@ jobs:
mkdir -p "${STAGE}/static"
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api
go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api
cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/"
cp -R static/admin "${STAGE}/static/admin"

View File

@@ -123,5 +123,7 @@ jobs:
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
BUILD_VERSION=${{ steps.next_version.outputs.new_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,128 +1,130 @@
name: Release to Aliyun CR
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到阿里云
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USER }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
name: Release to Aliyun CR
on:
workflow_dispatch:
inputs:
version_type:
description: '版本类型'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: get_version
run: |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
TAG_VERSION=${LATEST_TAG#v}
if [ -f VERSION ]; then
FILE_VERSION=$(cat VERSION | tr -d '[:space:]')
else
FILE_VERSION="0.0.0"
fi
function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; }
if version_gt "$FILE_VERSION" "$TAG_VERSION"; then
VERSION="$FILE_VERSION"
else
VERSION="$TAG_VERSION"
fi
echo "Current version: $VERSION"
echo "current_version=$VERSION" >> $GITHUB_OUTPUT
- name: Calculate next version
id: next_version
env:
VERSION_TYPE: ${{ github.event.inputs.version_type }}
run: |
VERSION="${{ steps.get_version.outputs.current_version }}"
BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//')
IFS='.' read -r -a version_parts <<< "$BASE_VERSION"
MAJOR="${version_parts[0]:-0}"
MINOR="${version_parts[1]:-0}"
PATCH="${version_parts[2]:-0}"
case "$VERSION_TYPE" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
*)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update VERSION file
run: |
echo "${{ steps.next_version.outputs.new_version }}" > VERSION
- name: Commit VERSION and create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]"
fi
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
git tag -a "$NEW_TAG" -m "Release $NEW_TAG"
git push origin HEAD:main "$NEW_TAG"
# Docker 构建并推送到阿里云
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.ALIYUN_REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USER }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }}
${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest
labels: |
org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
BUILD_VERSION=${{ steps.next_version.outputs.new_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -623,6 +623,7 @@ Reads runtime settings and status, including:
- `admin` (JWT expiry, default-password warning, etc.)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`)
- `toolcall` / `responses` / `embeddings`
- `auto_delete` (`sessions`)
- `claude_mapping` / `model_aliases`
- `env_backed`, `needs_vercel_sync`
@@ -635,6 +636,7 @@ Hot-updates runtime settings. Supported fields:
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`

2
API.md
View File

@@ -628,6 +628,7 @@ data: {"type":"message_stop"}
- `admin`JWT 过期、默认密码告警等)
- `runtime``account_max_inflight``account_max_queue``global_max_inflight`
- `toolcall` / `responses` / `embeddings`
- `auto_delete``sessions`
- `claude_mapping` / `model_aliases`
- `env_backed``needs_vercel_sync`
@@ -640,6 +641,7 @@ data: {"type":"message_stop"}
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`

View File

@@ -181,6 +181,7 @@ 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.
- **Build version**: Zeabur / regular `docker build` does not require `BUILD_VERSION` by default. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent.
- **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).
---

View File

@@ -181,6 +181,7 @@ healthcheck:
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
- **构建版本号**Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
---

View File

@@ -10,19 +10,24 @@ FROM golang:1.24 AS go-builder
WORKDIR /app
ARG TARGETOS
ARG TARGETARCH
ARG BUILD_VERSION
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN set -eux; \
GOOS="${TARGETOS:-$(go env GOOS)}"; \
GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api
BUILD_VERSION_RESOLVED="${BUILD_VERSION:-}"; \
if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api
FROM busybox:1.36.1-musl AS busybox-tools
FROM debian:bookworm-slim AS runtime-base
WORKDIR /app
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
EXPOSE 5001
CMD ["/usr/local/bin/ds2api"]

View File

@@ -178,6 +178,8 @@ docker-compose logs -f
2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。
3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。
说明Zeabur 使用仓库内 `Dockerfile` 直接构建时,不需要额外传入 `BUILD_VERSION`;镜像会优先读取该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
### 方式三Vercel 部署
1. Fork 仓库到自己的 GitHub
@@ -242,13 +244,11 @@ cp opencode.json.example opencode.json
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
"password": "your-password"
}
],
"model_aliases": {
@@ -269,7 +269,7 @@ cp opencode.json.example opencode.json
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
@@ -280,21 +280,25 @@ cp opencode.json.example opencode.json
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}
```
- `keys`API 访问密钥列表,客户端通过 `Authorization: Bearer <key>` 鉴权
- `accounts`DeepSeek 账号列表,支持 `email` 或 `mobile` 登录
- `token`留空则首次请求时自动登录获取;也可预填已有 token
- `token`配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新
- `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
- `toolcall`:固定采用特征匹配 + 高置信早发策略
- `responses.store_ttl_seconds``/v1/responses/{id}` 的内存缓存 TTL
- `embeddings.provider`embedding 提供方(当前内置 `deterministic/mock/builtin`
- `claude_model_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型
- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`
- `admin`管理后台设置JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算
- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新)
### 环境变量

View File

@@ -178,6 +178,8 @@ Rebuild after updates: `docker-compose up -d --build`
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`).
Note: when Zeabur builds directly from the repo `Dockerfile`, you do not need to pass `BUILD_VERSION`. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent.
### Option 3: Vercel
1. Fork this repo to your GitHub account
@@ -242,13 +244,11 @@ cp opencode.json.example opencode.json
"accounts": [
{
"email": "user@example.com",
"password": "your-password",
"token": ""
"password": "your-password"
},
{
"mobile": "12345678901",
"password": "your-password",
"token": ""
"password": "your-password"
}
],
"model_aliases": {
@@ -269,7 +269,7 @@ cp opencode.json.example opencode.json
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
@@ -280,21 +280,25 @@ cp opencode.json.example opencode.json
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}
```
- `keys`: API access keys; clients authenticate via `Authorization: Bearer <key>`
- `accounts`: DeepSeek account list, supports `email` or `mobile` login
- `token`: Leave empty for auto-login on first request; or pre-fill an existing token
- `token`: Even if set in `config.json`, it is cleared during load (DS2API does not read persisted tokens from config); runtime tokens are maintained/refreshed in memory only
- `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models
- `compat.wide_input_strict_output`: Keep `true` (current default policy)
- `toolcall`: Fixed to feature matching + high-confidence early emit
- `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}`
- `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in)
- `claude_model_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models
- `claude_mapping`: Maps `fast`/`slow` suffixes to corresponding DeepSeek models (still compatible with `claude_model_mapping`)
- `admin`: Admin panel settings (JWT expiry, password hash, etc.), hot-reloadable via Admin Settings API
- `runtime`: Runtime parameters (concurrency limits, queue sizes), hot-reloadable via Admin Settings API
- `runtime`: Runtime parameters (concurrency limits, queue sizes), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values
- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings)
### Environment Variables

View File

@@ -1 +1 @@
0.1.0
2.4.0

View File

@@ -9,20 +9,17 @@
{
"_comment": "邮箱登录方式",
"email": "example1@example.com",
"password": "your-password-1",
"token": ""
"password": "your-password-1"
},
{
"_comment": "邮箱登录方式 - 账号2",
"email": "example2@example.com",
"password": "your-password-2",
"token": ""
"password": "your-password-2"
},
{
"_comment": "手机号登录方式(中国大陆)",
"mobile": "12345678901",
"password": "your-password-3",
"token": ""
"password": "your-password-3"
}
],
"model_aliases": {
@@ -43,8 +40,19 @@
"embeddings": {
"provider": "deterministic"
},
"claude_model_mapping": {
"claude_mapping": {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner"
},
"admin": {
"jwt_expire_hours": 24
},
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
},
"auto_delete": {
"sessions": false
}
}

View File

@@ -194,7 +194,7 @@ func TestPoolAccountConcurrencyAliasEnv(t *testing.T) {
}
}
func TestPoolSupportsTokenOnlyAccount(t *testing.T) {
func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
@@ -203,19 +203,15 @@ func TestPoolSupportsTokenOnlyAccount(t *testing.T) {
pool := NewPool(config.LoadStore())
status := pool.Status()
if got, ok := status["total"].(int); !ok || got != 1 {
if got, ok := status["total"].(int); !ok || got != 0 {
t.Fatalf("unexpected total in pool status: %#v", status["total"])
}
if got, ok := status["available"].(int); !ok || got != 1 {
if got, ok := status["available"].(int); !ok || got != 0 {
t.Fatalf("unexpected available in pool status: %#v", status["available"])
}
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("expected acquire success for token-only account")
}
if acc.Token != "token-only-account" {
t.Fatalf("unexpected token on acquired account: %q", acc.Token)
if _, ok := pool.Acquire("", nil); ok {
t.Fatalf("expected acquire to fail for token-only account")
}
}

View File

@@ -358,7 +358,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
}
}
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}",
@@ -379,8 +379,8 @@ func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use for fenced example, body=%s", rec.Body.String())
if foundToolUse {
t.Fatalf("expected no tool_use for fenced example, body=%s", rec.Body.String())
}
foundToolStop := false
@@ -391,7 +391,12 @@ func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.
break
}
}
if !foundToolStop {
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
if foundToolStop {
t.Fatalf("expected stop_reason to remain content-only, body=%s", rec.Body.String())
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t)
}

View File

@@ -48,10 +48,85 @@ func TestNormalizeClaudeMessagesToolResult(t *testing.T) {
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", m["role"])
}
content, _ := m["content"].(string)
if !strings.Contains(content, "[TOOL_RESULT_HISTORY]") || !strings.Contains(content, "content: tool output") {
t.Fatalf("expected serialized tool result marker, got %q", content)
if content != "tool output" {
t.Fatalf("expected raw tool output content preserved, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
msgs := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_1",
"name": "search_web",
"input": map[string]any{"query": "latest"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized tool-call message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "assistant" {
t.Fatalf("expected assistant role, got %#v", m["role"])
}
tc, _ := m["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", m["tool_calls"])
}
call, _ := tc[0].(map[string]any)
if call["id"] != "call_1" {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "search_web") || !containsStr(content, `"arguments":"{\"query\":\"latest\"}"`) {
t.Fatalf("expected assistant content to include serialized tool call for prompt roundtrip, got %q", content)
}
}
func TestNormalizeClaudeMessagesDoesNotPromoteUserToolUse(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_use",
"id": "call_unsafe",
"name": "dangerous_tool",
"input": map[string]any{"value": "x"},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "user" {
t.Fatalf("expected user role preserved, got %#v", m["role"])
}
if _, ok := m["tool_calls"]; ok {
t.Fatalf("expected no tool_calls promotion for user message, got %#v", m["tool_calls"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_use"`) || !containsStr(content, "dangerous_tool") {
t.Fatalf("expected raw tool_use block preserved in user content, got %q", content)
}
}
@@ -87,15 +162,63 @@ func TestNormalizeClaudeMessagesMixedContentBlocks(t *testing.T) {
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "Hello"},
map[string]any{"type": "image", "source": "data:..."},
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "data": strings.Repeat("A", 2048)}},
map[string]any{"type": "text", "text": "World"},
},
},
}
got := normalizeClaudeMessages(msgs)
m := got[0].(map[string]any)
if m["content"] != "Hello\nWorld" {
t.Fatalf("expected only text parts joined, got %q", m["content"])
content, _ := m["content"].(string)
if !containsStr(content, "Hello") || !containsStr(content, "World") || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected text plus non-text block marker preserved, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary payload omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T) {
msgs := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "call_image_1",
"name": "vision_tool",
"content": []any{
map[string]any{"type": "text", "text": "image analysis"},
map[string]any{
"type": "image",
"source": map[string]any{"type": "base64", "media_type": "image/png", "data": strings.Repeat("B", 2048)},
},
},
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %d", len(got))
}
m := got[0].(map[string]any)
if m["role"] != "tool" {
t.Fatalf("expected tool role, got %#v", m["role"])
}
content, _ := m["content"].(string)
if !containsStr(content, `"type":"tool_result"`) || !containsStr(content, `"type":"image"`) {
t.Fatalf("expected non-text tool_result payload to be JSON stringified, got %q", content)
}
if !containsStr(content, omittedBinaryMarker) {
t.Fatalf("expected binary data to be sanitized with omitted marker, got %q", content)
}
if containsStr(content, strings.Repeat("B", 100)) {
t.Fatalf("expected raw base64 payload not to be included, got %q", content)
}
}
@@ -128,8 +251,11 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "tool_use") {
t.Fatalf("expected tool_use instruction in prompt")
}
if containsStr(prompt, "tool_calls") {
t.Fatalf("expected prompt to avoid tool_calls JSON instruction")
if containsStr(prompt, "TOOL_CALL_HISTORY") || containsStr(prompt, "TOOL_RESULT_HISTORY") {
t.Fatalf("expected legacy tool history markers removed from prompt")
}
if !containsStr(prompt, "Do not print tool-call JSON in text") {
t.Fatalf("expected prompt to keep no tool-call-json instruction")
}
}

View File

@@ -13,28 +13,58 @@ func normalizeClaudeMessages(messages []any) []any {
if !ok {
continue
}
copied := cloneMap(msg)
role := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", msg["role"])))
switch content := msg["content"].(type) {
case []any:
parts := make([]string, 0, len(content))
textParts := make([]string, 0, len(content))
flushText := func() {
if len(textParts) == 0 {
return
}
out = append(out, map[string]any{
"role": role,
"content": strings.Join(textParts, "\n"),
})
textParts = textParts[:0]
}
for _, block := range content {
b, ok := block.(map[string]any)
if !ok {
continue
}
typeStr, _ := b["type"].(string)
if typeStr == "text" {
typeStr := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", b["type"])))
switch typeStr {
case "text":
if t, ok := b["text"].(string); ok {
parts = append(parts, t)
textParts = append(textParts, t)
}
case "tool_use":
if role == "assistant" {
flushText()
if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil {
out = append(out, toolMsg)
}
continue
}
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
case "tool_result":
flushText()
if toolMsg := normalizeClaudeToolResultToToolMessage(b); toolMsg != nil {
out = append(out, toolMsg)
}
default:
if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" {
textParts = append(textParts, raw)
}
}
if typeStr == "tool_result" {
parts = append(parts, formatClaudeToolResultForPrompt(b))
}
}
copied["content"] = strings.Join(parts, "\n")
flushText()
default:
copied := cloneMap(msg)
out = append(out, copied)
}
out = append(out, copied)
}
return out
}
@@ -52,8 +82,8 @@ func buildClaudeToolPrompt(tools []any) string {
}
parts = append(parts,
"When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.",
"History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.",
"After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.",
"Tool roundtrip context is included directly in the conversation messages (assistant tool_use/tool_calls and tool results).",
"After receiving a valid tool result, continue with final answer instead of repeating the same call unless required fields are still missing.",
)
return strings.Join(parts, "\n\n")
}
@@ -62,22 +92,111 @@ func formatClaudeToolResultForPrompt(block map[string]any) string {
if block == nil {
return ""
}
payload := map[string]any{
"type": "tool_result",
"content": block["content"],
}
if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
} else if toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])); toolCallID != "" {
payload["tool_call_id"] = toolCallID
}
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
payload["name"] = name
}
b, err := json.Marshal(payload)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", payload))
}
return string(b)
}
func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
if block == nil {
return nil
}
name := strings.TrimSpace(fmt.Sprintf("%v", block["name"]))
if name == "" {
return nil
}
callID := strings.TrimSpace(fmt.Sprintf("%v", block["id"]))
if callID == "" {
callID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
}
if callID == "" {
callID = "call_claude"
}
arguments := block["input"]
if arguments == nil {
arguments = map[string]any{}
}
argsJSON, err := json.Marshal(arguments)
if err != nil || len(argsJSON) == 0 {
argsJSON = []byte("{}")
}
toolCalls := []any{
map[string]any{
"id": callID,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(argsJSON),
},
},
}
return map[string]any{
"role": "assistant",
"content": marshalCompactJSON(toolCalls),
"tool_calls": toolCalls,
}
}
func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any {
if block == nil {
return nil
}
toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
if toolCallID == "" {
toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"]))
}
if toolCallID == "" {
toolCallID = "unknown"
toolCallID = "call_claude"
}
name := strings.TrimSpace(fmt.Sprintf("%v", block["name"]))
if name == "" {
name = "unknown"
out := map[string]any{
"role": "tool",
"tool_call_id": toolCallID,
"content": normalizeClaudeToolResultContent(block["content"]),
}
content := strings.TrimSpace(fmt.Sprintf("%v", block["content"]))
if content == "" {
content = "null"
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
out["name"] = name
}
return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content)
return out
}
func normalizeClaudeToolResultContent(content any) any {
if text, ok := content.(string); ok {
return text
}
payload := map[string]any{
"type": "tool_result",
"content": content,
}
b, err := json.Marshal(sanitizeClaudeBlockForPrompt(payload))
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", content))
}
return string(b)
}
func formatClaudeBlockRaw(block map[string]any) string {
if block == nil {
return ""
}
b, err := json.Marshal(block)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", block))
}
return string(b)
}
func hasSystemMessage(messages []any) bool {

View File

@@ -0,0 +1,105 @@
package claude
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxClaudeRawPromptChars = 1024
omittedBinaryMarker = "[omitted_binary_payload]"
)
func formatClaudeUnknownBlockForPrompt(block map[string]any) string {
if block == nil {
return ""
}
safe := sanitizeClaudeBlockForPrompt(block)
raw := strings.TrimSpace(formatClaudeBlockRaw(safe))
if raw == "" {
return ""
}
if len(raw) > maxClaudeRawPromptChars {
return raw[:maxClaudeRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeClaudeBlockForPrompt(block map[string]any) map[string]any {
out := cloneMap(block)
for k, v := range out {
if looksLikeBinaryFieldName(k) {
out[k] = omittedBinaryMarker
continue
}
switch inner := v.(type) {
case map[string]any:
out[k] = sanitizeClaudeBlockForPrompt(inner)
case []any:
out[k] = sanitizeClaudeArrayForPrompt(inner)
case string:
out[k] = sanitizeClaudeStringForPrompt(k, inner)
}
}
return out
}
func sanitizeClaudeArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch v := item.(type) {
case map[string]any:
out = append(out, sanitizeClaudeBlockForPrompt(v))
case []any:
out = append(out, sanitizeClaudeArrayForPrompt(v))
default:
out = append(out, v)
}
}
return out
}
func sanitizeClaudeStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeBinaryFieldName(key) || looksLikeBase64Payload(trimmed) {
return omittedBinaryMarker
}
if len(trimmed) > maxClaudeRawPromptChars {
return trimmed[:maxClaudeRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeBinaryFieldName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "base64" || n == "inline_data" || n == "inlinedata"
}
func looksLikeBase64Payload(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}
func marshalCompactJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return string(b)
}

View File

@@ -2,6 +2,8 @@ package gemini
import "strings"
const maxGeminiRawPromptChars = 1024
func geminiMessagesFromRequest(req map[string]any) []any {
out := make([]any, 0, 8)
if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" {
@@ -107,6 +109,11 @@ func geminiMessagesFromRequest(req map[string]any) []any {
msg["name"] = name
}
out = append(out, msg)
continue
}
if raw := strings.TrimSpace(formatGeminiUnknownPartForPrompt(part)); raw != "" && raw != "null" {
textParts = append(textParts, raw)
}
}
flushText()
@@ -151,3 +158,87 @@ func mapGeminiRole(v any) string {
return ""
}
}
func formatGeminiUnknownPartForPrompt(part map[string]any) string {
safe := sanitizeGeminiPartForPrompt(part)
raw := strings.TrimSpace(stringifyJSON(safe))
if raw == "" {
return ""
}
if len(raw) > maxGeminiRawPromptChars {
return raw[:maxGeminiRawPromptChars] + "...(truncated)"
}
return raw
}
func sanitizeGeminiPartForPrompt(part map[string]any) map[string]any {
out := make(map[string]any, len(part))
for k, v := range part {
if looksLikeGeminiBinaryField(k) {
out[k] = "[omitted_binary_payload]"
continue
}
switch x := v.(type) {
case map[string]any:
out[k] = sanitizeGeminiPartForPrompt(x)
case []any:
out[k] = sanitizeGeminiArrayForPrompt(x)
case string:
out[k] = sanitizeGeminiStringForPrompt(k, x)
default:
out[k] = v
}
}
return out
}
func sanitizeGeminiArrayForPrompt(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
switch x := item.(type) {
case map[string]any:
out = append(out, sanitizeGeminiPartForPrompt(x))
case []any:
out = append(out, sanitizeGeminiArrayForPrompt(x))
default:
out = append(out, x)
}
}
return out
}
func sanitizeGeminiStringForPrompt(key, value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if looksLikeGeminiBinaryField(key) || looksLikeGeminiBase64(trimmed) {
return "[omitted_binary_payload]"
}
if len(trimmed) > maxGeminiRawPromptChars {
return trimmed[:maxGeminiRawPromptChars] + "...(truncated)"
}
return trimmed
}
func looksLikeGeminiBinaryField(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
return n == "data" || n == "bytes" || n == "inlinedata" || n == "inline_data" || n == "base64"
}
func looksLikeGeminiBase64(v string) bool {
if len(v) < 512 {
return false
}
compact := strings.TrimRight(v, "=")
if compact == "" {
return false
}
for _, ch := range compact {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '+' || ch == '/' || ch == '-' || ch == '_' {
continue
}
return false
}
return true
}

View File

@@ -0,0 +1,84 @@
package gemini
import (
"strings"
"testing"
)
func TestGeminiMessagesFromRequestPreservesFunctionRoundtrip(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "model",
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"id": "call_g1",
"name": "search_web",
"args": map[string]any{"query": "ai"},
},
},
},
},
map[string]any{
"role": "user",
"parts": []any{
map[string]any{
"functionResponse": map[string]any{
"id": "call_g1",
"name": "search_web",
"response": "ok",
},
},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 2 {
t.Fatalf("expected two normalized messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("expected assistant first, got %#v", assistant)
}
tc, _ := assistant["tool_calls"].([]any)
if len(tc) != 1 {
t.Fatalf("expected one tool call, got %#v", assistant["tool_calls"])
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["role"] != "tool" || toolMsg["tool_call_id"] != "call_g1" {
t.Fatalf("expected tool message with call id, got %#v", toolMsg)
}
}
func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "user",
"parts": []any{
map[string]any{"text": "hello"},
map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": strings.Repeat("A", 2048)}},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 1 {
t.Fatalf("expected one normalized message, got %#v", got)
}
msg, _ := got[0].(map[string]any)
content, _ := msg["content"].(string)
if !strings.Contains(content, "hello") || !strings.Contains(content, "inlineData") {
t.Fatalf("expected unknown part preserved as raw json text, got %q", content)
}
if !strings.Contains(content, "[omitted_binary_payload]") {
t.Fatalf("expected inlineData payload to be redacted, got %q", content)
}
if strings.Contains(content, strings.Repeat("A", 100)) {
t.Fatalf("expected raw base64 payload not to be embedded, got %q", content)
}
}

View File

@@ -97,7 +97,7 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := s.text.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
@@ -141,8 +141,12 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
if evt.Content == "" {
continue
}
cleaned := sanitizeLeakedToolHistory(evt.Content)
if cleaned == "" {
continue
}
delta := map[string]any{
"content": evt.Content,
"content": cleaned,
}
if !s.firstChunkSent {
delta["role"] = "assistant"
@@ -246,8 +250,12 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
continue
}
if evt.Content != "" {
cleaned := sanitizeLeakedToolHistory(evt.Content)
if cleaned == "" {
continue
}
contentDelta := map[string]any{
"content": evt.Content,
"content": cleaned,
}
if !s.firstChunkSent {
contentDelta["role"] = "assistant"

View File

@@ -105,7 +105,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
result := sse.CollectStream(resp, thinkingEnabled, true)
finalThinking := result.Thinking
finalText := result.Text
finalText := sanitizeLeakedToolHistory(result.Text)
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
writeJSON(w, http.StatusOK, respBody)
}
@@ -128,8 +128,8 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
}
created := time.Now().Unix()
bufferToolContent := len(toolNames) > 0 && h.toolcallFeatureMatchEnabled()
emitEarlyToolDeltas := h.toolcallEarlyEmitHighConfidence()
bufferToolContent := len(toolNames) > 0
emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence()
initialType := "text"
if thinkingEnabled {
initialType = "thinking"

View File

@@ -53,13 +53,13 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 {
return messages, names
}
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON object format:\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON object above. Do NOT include any extra text.\n2) Do NOT wrap tool-call JSON in markdown/code fences (for example, do not use triple backticks).\n3) After receiving a tool result, you MUST use it to produce the final answer.\n4) Only call another tool when the previous result is missing required data or returned an error.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list."
}
if policy.Mode == util.ToolChoiceForced && strings.TrimSpace(policy.ForcedName) != "" {
toolPrompt += "\n5) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
toolPrompt += "\n6) Do not call any other tool."
toolPrompt += "\n7) For this response, you MUST call exactly this tool name: " + strings.TrimSpace(policy.ForcedName)
toolPrompt += "\n8) Do not call any other tool."
}
for i := range messages {

View File

@@ -2,12 +2,6 @@ package openai
import "strings"
func applyOpenAIChatPassThrough(req map[string]any, payload map[string]any) {
for k, v := range collectOpenAIChatPassThrough(req) {
payload[k] = v
}
}
func (h *Handler) toolcallFeatureMatchEnabled() bool {
if h == nil || h.Store == nil {
return true

View File

@@ -243,7 +243,7 @@ func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
}
}
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
func TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
@@ -259,20 +259,25 @@ func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
if choice["finish_reason"] == "tool_calls" {
t.Fatalf("expected fenced example to remain content-only, got finish_reason=%#v", choice["finish_reason"])
}
msg, _ := choice["message"].(map[string]any)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
if len(toolCalls) != 0 {
t.Fatalf("expected no tool_call field for fenced example: %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
if !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced example content preserved, got %q", content)
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
TestHandleNonStreamFencedToolCallExampleDoesNotPromoteToolCall(t)
}
func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
@@ -651,6 +656,48 @@ func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
if strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
}
if strings.Contains(strings.ToLower(got), "```json") || strings.Contains(got, "\n```\n") {
t.Fatalf("expected consumed fenced tool payload to not leave empty code fence, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamStandaloneToolCallAfterClosedFenceKeepsFence(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "先给一个代码示例:\n```text\nhello\n```\n"),
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}"),
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid7g", "deepseek-chat", "prompt", false, false, []string{"search"})
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for standalone payload, body=%s", rec.Body.String())
}
content := strings.Builder{}
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if c, ok := delta["content"].(string); ok {
content.WriteString(c)
}
}
}
got := content.String()
if !strings.Contains(got, "```") {
t.Fatalf("expected closed fence before standalone tool json to be preserved, got=%q", got)
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}

View File

@@ -5,11 +5,11 @@ import (
"fmt"
"strings"
"ds2api/internal/config"
"ds2api/internal/prompt"
)
func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any {
_ = traceID
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
msg, ok := item.(map[string]any)
@@ -19,20 +19,19 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
role := strings.ToLower(strings.TrimSpace(asString(msg["role"])))
switch role {
case "assistant":
content := normalizeOpenAIContentForPrompt(msg["content"])
toolCalls := formatAssistantToolCallsForPrompt(msg, traceID)
combined := joinNonEmpty(content, toolCalls)
if combined == "" {
content := buildAssistantContentForPrompt(msg)
if content == "" {
continue
}
out = append(out, map[string]any{
"role": "assistant",
"content": combined,
"content": content,
})
case "tool", "function":
content := buildToolContentForPrompt(msg)
out = append(out, map[string]any{
"role": "user",
"content": formatToolResultForPrompt(msg),
"role": "tool",
"content": content,
})
case "user", "system", "developer":
out = append(out, map[string]any{
@@ -56,95 +55,54 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
return out
}
func formatAssistantToolCallsForPrompt(msg map[string]any, traceID string) string {
entries := make([]string, 0)
if calls, ok := msg["tool_calls"].([]any); ok {
for i, item := range calls {
call, ok := item.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(asString(call["id"]))
if id == "" {
id = fmt.Sprintf("call_%d", i+1)
}
name := strings.TrimSpace(asString(call["name"]))
args := ""
if fn, ok := call["function"].(map[string]any); ok {
if name == "" {
name = strings.TrimSpace(asString(fn["name"]))
}
args = normalizeOpenAIArgumentsForPrompt(fn["arguments"])
}
if name == "" {
continue
}
if args == "" {
args = normalizeOpenAIArgumentsForPrompt(call["arguments"])
}
if args == "" {
args = normalizeOpenAIArgumentsForPrompt(call["input"])
}
if args == "" {
args = "{}"
}
maybeWarnSuspiciousToolHistory(traceID, id, name, args)
entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: %s\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", id, name, args))
}
func buildAssistantContentForPrompt(msg map[string]any) string {
content := normalizeOpenAIContentForPrompt(msg["content"])
toolCalls := normalizeAssistantToolCallsForPrompt(msg["tool_calls"])
if toolCalls == "" {
return strings.TrimSpace(content)
}
if legacy, ok := msg["function_call"].(map[string]any); ok {
name := strings.TrimSpace(asString(legacy["name"]))
if name == "" {
name = "unknown"
}
args := normalizeOpenAIArgumentsForPrompt(legacy["arguments"])
if args == "" {
args = "{}"
}
maybeWarnSuspiciousToolHistory(traceID, "call_legacy", name, args)
entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: call_legacy\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", name, args))
if strings.TrimSpace(content) == "" {
return toolCalls
}
return strings.Join(entries, "\n\n")
return strings.TrimSpace(content + "\n" + toolCalls)
}
func formatToolResultForPrompt(msg map[string]any) string {
toolCallID := strings.TrimSpace(asString(msg["tool_call_id"]))
if toolCallID == "" {
toolCallID = strings.TrimSpace(asString(msg["id"]))
func normalizeAssistantToolCallsForPrompt(v any) string {
calls, ok := v.([]any)
if !ok || len(calls) == 0 {
return ""
}
if toolCallID == "" {
toolCallID = "unknown"
b, err := json.Marshal(calls)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", calls))
}
return strings.TrimSpace(string(b))
}
name := strings.TrimSpace(asString(msg["name"]))
if name == "" {
name = "unknown"
func buildToolContentForPrompt(msg map[string]any) string {
payload := map[string]any{
"content": msg["content"],
}
content := normalizeOpenAIContentForPrompt(msg["content"])
if content == "" {
content = "null"
if id := strings.TrimSpace(asString(msg["tool_call_id"])); id != "" {
payload["tool_call_id"] = id
}
return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content)
if id := strings.TrimSpace(asString(msg["id"])); id != "" {
payload["id"] = id
}
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
payload["name"] = name
}
content := normalizeOpenAIContentForPrompt(payload)
if strings.TrimSpace(content) == "" {
return `{"content":"null"}`
}
return content
}
func normalizeOpenAIContentForPrompt(v any) string {
return prompt.NormalizeContent(v)
}
func normalizeOpenAIArgumentsForPrompt(v any) string {
switch x := v.(type) {
case string:
return normalizeToolArgumentString(x)
default:
return marshalToPromptString(v)
}
}
func normalizeToolArgumentString(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
@@ -157,14 +115,6 @@ func normalizeToolArgumentString(raw string) string {
return trimmed
}
func marshalToPromptString(v any) string {
b, err := json.Marshal(v)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
return string(b)
}
func normalizeOpenAIRoleForPrompt(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
if role == "developer" {
@@ -180,34 +130,6 @@ func asString(v any) string {
return ""
}
func joinNonEmpty(parts ...string) string {
nonEmpty := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) == "" {
continue
}
nonEmpty = append(nonEmpty, p)
}
return strings.Join(nonEmpty, "\n\n")
}
func maybeWarnSuspiciousToolHistory(traceID, callID, name, args string) {
if !looksLikeConcatenatedJSON(args) {
return
}
traceID = strings.TrimSpace(traceID)
if traceID == "" {
traceID = "unknown"
}
config.Logger.Warn(
"[openai] suspicious tool call history payload detected",
"trace_id", traceID,
"tool_call_id", strings.TrimSpace(callID),
"name", strings.TrimSpace(name),
"arguments_preview", previewToolArgs(args, 160),
)
}
func looksLikeConcatenatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
@@ -224,11 +146,3 @@ func looksLikeConcatenatedJSON(raw string) bool {
var second any
return dec.Decode(&second) == nil
}
func previewToolArgs(raw string, max int) string {
trimmed := strings.TrimSpace(raw)
if max <= 0 || len(trimmed) <= max {
return trimmed
}
return trimmed[:max]
}

View File

@@ -35,23 +35,19 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 4 {
t.Fatalf("expected 4 normalized messages, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "[TOOL_CALL_HISTORY]") ||
!strings.Contains(assistantContent, "tool_call_id: call_1") ||
!strings.Contains(assistantContent, "function.name: get_weather") ||
!strings.Contains(assistantContent, "function.arguments: {\"city\":\"beijing\"}") {
t.Fatalf("assistant tool call not serialized correctly: %q", assistantContent)
t.Fatalf("expected 4 normalized messages with assistant tool_call history preserved, got %d", len(normalized))
}
toolContent, _ := normalized[3]["content"].(string)
if !strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") || !strings.Contains(toolContent, "name: get_weather") {
t.Fatalf("tool result not serialized correctly: %q", toolContent)
if !strings.Contains(toolContent, `\"temp\":18`) {
t.Fatalf("tool result should be transparently forwarded, got %q", toolContent)
}
if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("tool history marker should not be injected: %q", toolContent)
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "tool_call_id: call_1") || !strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("expected prompt to include tool call + result semantics: %q", prompt)
if strings.Contains(prompt, "[TOOL_CALL_HISTORY]") || strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("expected no synthetic history markers in prompt: %q", prompt)
}
}
@@ -91,8 +87,8 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) {
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "line-1\nline-2") {
t.Fatalf("expected joined text blocks, got %q", got)
if !strings.Contains(got, `"line-1"`) || !strings.Contains(got, `"line-2"`) || !strings.Contains(got, `"name":"read_file"`) {
t.Fatalf("expected tool envelope to preserve content blocks and metadata, got %q", got)
}
}
@@ -112,15 +108,42 @@ func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) {
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
}
if normalized[0]["role"] != "user" {
t.Fatalf("expected function role mapped to user, got %#v", normalized[0]["role"])
if normalized[0]["role"] != "tool" {
t.Fatalf("expected function role normalized as tool, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "name: legacy_tool") || !strings.Contains(got, `"ok":true`) {
if !strings.Contains(got, `"name":"legacy_tool"`) || !strings.Contains(got, `"ok":true`) {
t.Fatalf("unexpected normalized function-role content: %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *testing.T) {
raw := []any{
map[string]any{
"role": "tool",
"tool_call_id": "call_5",
"name": "noop_tool",
"content": "",
},
map[string]any{
"role": "assistant",
"content": "done",
},
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 2 {
t.Fatalf("expected tool completion turn to be preserved, got %#v", normalized)
}
if normalized[0]["role"] != "tool" {
t.Fatalf("expected tool role preserved, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"content":""`) || !strings.Contains(got, `"name":"noop_tool"`) || !strings.Contains(got, `"tool_call_id":"call_5"`) {
t.Fatalf("expected tool metadata preserved in content envelope, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSeparated(t *testing.T) {
raw := []any{
map[string]any{
@@ -148,23 +171,11 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized assistant message, got %d", len(normalized))
t.Fatalf("expected assistant tool_call-only message to be preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "[TOOL_CALL_HISTORY]") != 2 {
t.Fatalf("expected two TOOL_CALL_HISTORY blocks, got %q", content)
}
if !strings.Contains(content, "tool_call_id: call_search") || !strings.Contains(content, "function.name: search_web") {
t.Fatalf("missing first tool call block, got %q", content)
}
if !strings.Contains(content, "tool_call_id: call_eval") || !strings.Contains(content, "function.name: eval_javascript") {
t.Fatalf("missing second tool call block, got %q", content)
}
if strings.Contains(content, "search_webeval_javascript") {
t.Fatalf("unexpected merged function name detected: %q", content)
}
if strings.Contains(content, `}{"`) {
t.Fatalf("unexpected concatenated function arguments detected: %q", content)
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"name":"search_web"`) || !strings.Contains(got, `"name":"eval_javascript"`) {
t.Fatalf("expected tool_calls payload preserved in assistant content, got %q", got)
}
}
@@ -186,15 +197,14 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
t.Fatalf("expected assistant tool_call-only content to be preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `function.arguments: {}{"query":"测试工具调用"}`) {
t.Fatalf("expected original concatenated arguments in tool history, got %q", content)
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `{}{\"query\":\"测试工具调用\"}`) {
t.Fatalf("expected concatenated arguments preserved verbatim, got %q", got)
}
}
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) {
raw := []any{
map[string]any{
@@ -212,8 +222,12 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 {
t.Fatalf("expected nameless assistant tool_calls to be dropped, got %#v", normalized)
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_calls history to be preserved even when name missing, got %#v", normalized)
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "call_missing_name") {
t.Fatalf("expected raw tool_call payload preserved, got %q", got)
}
}
@@ -236,14 +250,11 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected one normalized message, got %d", len(normalized))
t.Fatalf("expected nil-content assistant tool_call-only message to be preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Contains(content, "<Assistant>null") || strings.HasPrefix(strings.TrimSpace(content), "null") {
t.Fatalf("unexpected null literal injected into assistant tool history: %q", content)
}
if !strings.Contains(content, "function.name: send_file_to_user") {
t.Fatalf("expected tool history block preserved, got %q", content)
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "send_file_to_user") {
t.Fatalf("expected tool call payload preserved, got %q", got)
}
}

View File

@@ -44,11 +44,11 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if len(toolNames) != 1 || toolNames[0] != "get_weather" {
t.Fatalf("unexpected tool names: %#v", toolNames)
}
if !strings.Contains(finalPrompt, "tool_call_id: call_1") ||
!strings.Contains(finalPrompt, "function.name: get_weather") ||
!strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") ||
!strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt missing tool roundtrip semantics: %q", finalPrompt)
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "[TOOL_CALL_HISTORY]") || strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("handler finalPrompt should not include synthetic history markers: %q", finalPrompt)
}
}
@@ -77,7 +77,10 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
if !strings.Contains(finalPrompt, "Only call another tool when the previous result is missing required data or returned an error.") {
t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt)
if !strings.Contains(finalPrompt, "Do NOT wrap tool-call JSON in markdown/code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence instruction: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "```json") {
t.Fatalf("vercel prepare finalPrompt should not require fenced json tool calls: %q", finalPrompt)
}
}

View File

@@ -113,7 +113,8 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
result := sse.CollectStream(resp, thinkingEnabled, true)
textParsed := util.ParseStandaloneToolCallsDetailed(result.Text, toolNames)
sanitizedText := sanitizeLeakedToolHistory(result.Text)
textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
callCount := len(textParsed.Calls)
@@ -122,7 +123,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, result.Text, toolNames)
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, result.Thinking, sanitizedText, toolNames)
h.getResponseStore().put(owner, responseID, responseObj)
writeJSON(w, http.StatusOK, responseObj)
}
@@ -145,8 +146,8 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
if thinkingEnabled {
initialType = "thinking"
}
bufferToolContent := len(toolNames) > 0 && h.toolcallFeatureMatchEnabled()
emitEarlyToolDeltas := h.toolcallEarlyEmitHighConfidence()
bufferToolContent := len(toolNames) > 0
emitEarlyToolDeltas := h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence()
streamRuntime := newResponsesStreamRuntime(
w,

View File

@@ -19,6 +19,27 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
role := strings.ToLower(strings.TrimSpace(asString(m["role"])))
if role != "" {
if role == "assistant" {
out := map[string]any{
"role": "assistant",
}
if toolCalls, ok := m["tool_calls"].([]any); ok && len(toolCalls) > 0 {
out["tool_calls"] = toolCalls
}
content := m["content"]
if content == nil {
if txt, _ := m["text"].(string); strings.TrimSpace(txt) != "" {
content = txt
}
}
if content != nil {
out["content"] = content
}
if _, hasToolCalls := out["tool_calls"]; hasToolCalls || out["content"] != nil {
return out
}
return nil
}
content := m["content"]
if content == nil {
if txt, _ := m["text"].(string); strings.TrimSpace(txt) != "" {
@@ -28,10 +49,22 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
if content == nil {
return nil
}
return map[string]any{
out := map[string]any{
"role": normalizeOpenAIRoleForPrompt(role),
"content": content,
}
if role == "tool" || role == "function" {
if callID := strings.TrimSpace(asString(m["tool_call_id"])); callID != "" {
out["tool_call_id"] = callID
}
if callID := strings.TrimSpace(asString(m["call_id"])); callID != "" {
out["tool_call_id"] = callID
}
if name := strings.TrimSpace(asString(m["name"])); name != "" {
out["name"] = name
}
}
return out
}
itemType := strings.ToLower(strings.TrimSpace(asString(m["type"])))

View File

@@ -32,7 +32,6 @@ type responsesStreamRuntime struct {
toolCallsDoneEmitted bool
sieve toolStreamSieveState
thinkingSieve toolStreamSieveState
thinking strings.Builder
text strings.Builder
visibleText strings.Builder
@@ -98,7 +97,7 @@ func newResponsesStreamRuntime(
func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := s.text.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
@@ -169,15 +168,6 @@ func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCal
logRejected(textParsed, "text")
}
func (s *responsesStreamRuntime) hasFunctionCallDone() bool {
for _, done := range s.functionDone {
if done {
return true
}
}
return false
}
func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
if !parsed.Parsed {
return streamengine.ParsedDecision{}
@@ -204,12 +194,16 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
continue
}
s.text.WriteString(p.Text)
if !s.bufferToolContent {
s.emitTextDelta(p.Text)
cleanedText := sanitizeLeakedToolHistory(p.Text)
if cleanedText == "" {
continue
}
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, p.Text, s.toolNames), true)
s.text.WriteString(cleanedText)
if !s.bufferToolContent {
s.emitTextDelta(cleanedText)
continue
}
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, cleanedText, s.toolNames), true)
}
return streamengine.ParsedDecision{ContentSeen: contentSeen}

View File

@@ -675,18 +675,3 @@ func extractAllSSEEventPayloads(body, targetEvent string) []map[string]any {
}
return out
}
func asFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
default:
return 0
}
}

View File

@@ -0,0 +1,17 @@
package openai
import (
"regexp"
)
var leakedToolHistoryPattern = regexp.MustCompile(`(?is)\[TOOL_CALL_HISTORY\][\s\S]*?\[/TOOL_CALL_HISTORY\]|\[TOOL_RESULT_HISTORY\][\s\S]*?\[/TOOL_RESULT_HISTORY\]`)
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
func sanitizeLeakedToolHistory(text string) string {
if text == "" {
return text
}
out := leakedToolHistoryPattern.ReplaceAllString(text, "")
out = emptyJSONFencePattern.ReplaceAllString(out, "")
return out
}

View File

@@ -0,0 +1,106 @@
package openai
import "testing"
func TestSanitizeLeakedToolHistoryRemovesMarkerBlocks(t *testing.T) {
raw := "前缀\n[TOOL_CALL_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]\n后缀"
got := sanitizeLeakedToolHistory(raw)
if got != "前缀\n\n后缀" {
t.Fatalf("unexpected sanitized content: %q", got)
}
}
func TestSanitizeLeakedToolHistoryPreservesChunkWhitespace(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "trailing space kept",
raw: "Hello ",
want: "Hello ",
},
{
name: "leading newline kept",
raw: "\nworld",
want: "\nworld",
},
{
name: "surrounding whitespace around marker is preserved",
raw: "A \n[TOOL_RESULT_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]\n B",
want: "A \n\n B",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeLeakedToolHistory(tc.raw)
if got != tc.want {
t.Fatalf("unexpected sanitize result, want %q got %q", tc.want, got)
}
})
}
}
func TestSanitizeLeakedToolHistoryRemovesEmptyJSONFence(t *testing.T) {
raw := "before\n```json\n```\nafter"
got := sanitizeLeakedToolHistory(raw)
if got != "before\n\nafter" {
t.Fatalf("unexpected sanitized empty json fence: %q", got)
}
}
func TestFlushToolSieveDropsToolHistoryLeak(t *testing.T) {
var state toolStreamSieveState
chunk := "[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]"
evts := processToolSieveChunk(&state, chunk, []string{"exec"})
if len(evts) != 0 {
t.Fatalf("expected no immediate output before history block is complete, got %+v", evts)
}
flushed := flushToolSieve(&state, []string{"exec"})
if len(flushed) != 0 {
t.Fatalf("expected history block to be swallowed, got %+v", flushed)
}
}
func TestFlushToolSieveDropsToolResultHistoryLeak(t *testing.T) {
var state toolStreamSieveState
chunk := "[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]"
evts := processToolSieveChunk(&state, chunk, []string{"exec"})
if len(evts) != 0 {
t.Fatalf("expected no immediate output before result history block is complete, got %+v", evts)
}
flushed := flushToolSieve(&state, []string{"exec"})
if len(flushed) != 0 {
t.Fatalf("expected result history block to be swallowed, got %+v", flushed)
}
}
func TestProcessToolSieveChunkSplitsResultHistoryBoundary(t *testing.T) {
var state toolStreamSieveState
parts := []string{
"Hello ",
"[TOOL_RESULT_HISTORY]\nstatus: already_called\n",
"function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]",
"world",
}
var events []toolStreamEvent
for _, p := range parts {
events = append(events, processToolSieveChunk(&state, p, []string{"exec"})...)
}
events = append(events, flushToolSieve(&state, []string{"exec"})...)
var text string
for _, evt := range events {
if evt.Content != "" {
text += evt.Content
}
if len(evt.ToolCalls) > 0 {
t.Fatalf("did not expect parsed tool calls from history leak: %+v", evt.ToolCalls)
}
}
if text != "Hello world" {
t.Fatalf("expected clean text output preserving boundary spaces, got %q", text)
}
}

View File

@@ -167,7 +167,7 @@ func findToolSegmentStart(s string) int {
return -1
}
lower := strings.ToLower(s)
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
bestKeyIdx := -1
for _, kw := range keywords {
idx := strings.Index(lower, kw)
@@ -182,6 +182,9 @@ func findToolSegmentStart(s string) int {
if start < 0 {
start = bestKeyIdx
}
if fenceStart, ok := openFenceStartBefore(s, start); ok {
return fenceStart
}
return start
}
@@ -191,21 +194,23 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return "", nil, "", false
}
lower := strings.ToLower(captured)
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
keyIdx = idx
}
}
if keyIdx < 0 {
return "", nil, "", false
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
if blockStart, blockEnd, ok := extractToolHistoryBlock(captured, keyIdx); ok {
return captured[:blockStart], nil, captured[blockEnd:], true
}
start = keyIdx
}
obj, end, ok := extractJSONObjectFrom(captured, start)
@@ -226,5 +231,70 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
// For now, keep the original logic but rely on loose JSON repair.
return captured, nil, "", true
}
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
return prefixPart, parsed.Calls, suffixPart, true
}
func extractToolHistoryBlock(captured string, keyIdx int) (start int, end int, ok bool) {
if keyIdx < 0 || keyIdx >= len(captured) {
return 0, 0, false
}
rest := strings.ToLower(captured[keyIdx:])
switch {
case strings.HasPrefix(rest, "[tool_call_history]"):
closeTag := "[/tool_call_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
case strings.HasPrefix(rest, "[tool_result_history]"):
closeTag := "[/tool_result_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
default:
return 0, 0, false
}
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")
if fenceIdx < 0 {
return prefix, suffix
}
// Only strip when the trailing fence in prefix behaves like an opening fence.
// A legitimate closing fence before a standalone tool JSON must be preserved.
if strings.Count(trimmedPrefix[:fenceIdx+3], "```")%2 == 0 {
return prefix, suffix
}
fenceHeader := strings.TrimSpace(trimmedPrefix[fenceIdx+3:])
if fenceHeader != "" && !strings.EqualFold(fenceHeader, "json") {
return prefix, suffix
}
trimmedSuffix := strings.TrimLeft(suffix, " \t\r\n")
if !strings.HasPrefix(trimmedSuffix, "```") {
return prefix, suffix
}
consumedLeading := len(suffix) - len(trimmedSuffix)
return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:]
}
func openFenceStartBefore(s string, pos int) (int, bool) {
if pos <= 0 || pos > len(s) {
return -1, false
}
segment := s[:pos]
lastFence := strings.LastIndex(segment, "```")
if lastFence < 0 {
return -1, false
}
if strings.Count(segment, "```")%2 == 1 {
return lastFence, true
}
return -1, false
}

View File

@@ -1,288 +0,0 @@
package openai
import "strings"
func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
if state.disableDeltas {
return nil
}
captured := state.capture.String()
if captured == "" {
return nil
}
lower := strings.ToLower(captured)
keyIdx := strings.Index(lower, "tool_calls")
if keyIdx < 0 {
return nil
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
return nil
}
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
if hasMultiple {
state.disableDeltas = true
return nil
}
if !certainSingle {
// In uncertain phases (e.g. first call arrived but array not closed yet),
// avoid speculative deltas and wait for final parsed tool_calls payload.
return nil
}
callStart, ok := findFirstToolCallObjectStart(captured, keyIdx)
if !ok {
return nil
}
deltas := make([]toolCallDelta, 0, 2)
if state.toolName == "" {
name, ok := extractToolCallName(captured, callStart)
if !ok || name == "" {
return nil
}
state.toolName = name
}
if state.toolArgsStart < 0 {
argsStart, stringMode, ok := findToolCallArgsStart(captured, callStart)
if ok {
state.toolArgsString = stringMode
if stringMode {
state.toolArgsStart = argsStart + 1
} else {
state.toolArgsStart = argsStart
}
state.toolArgsSent = state.toolArgsStart
}
}
if !state.toolNameSent {
if state.toolArgsStart < 0 {
return nil
}
state.toolNameSent = true
deltas = append(deltas, toolCallDelta{Index: 0, Name: state.toolName})
}
if state.toolArgsStart < 0 || state.toolArgsDone {
return deltas
}
end, complete, ok := scanToolCallArgsProgress(captured, state.toolArgsStart, state.toolArgsString)
if !ok {
return deltas
}
if end > state.toolArgsSent {
deltas = append(deltas, toolCallDelta{
Index: 0,
Arguments: captured[state.toolArgsSent:end],
})
state.toolArgsSent = end
}
if complete {
state.toolArgsDone = true
}
return deltas
}
func classifyToolCallsIncrementalSafety(text string, keyIdx int) (certainSingle bool, hasMultiple bool) {
arrStart, ok := findToolCallsArrayStart(text, keyIdx)
if !ok {
return false, false
}
i := skipSpaces(text, arrStart+1)
if i >= len(text) || text[i] != '{' {
return false, false
}
count := 0
depth := 0
quote := byte(0)
escaped := false
for ; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' {
if depth == 0 {
count++
if count > 1 {
return false, true
}
}
depth++
continue
}
if ch == '}' {
if depth > 0 {
depth--
}
continue
}
if ch == ',' && depth == 0 {
// top-level separator means at least one more tool call exists
// (or is expected). Treat as multi-call and stop incremental deltas.
return false, true
}
if ch == ']' && depth == 0 {
return count == 1, false
}
}
// array not closed yet: still uncertain whether more calls will appear
return false, false
}
func findFirstToolCallObjectStart(text string, keyIdx int) (int, bool) {
arrStart, ok := findToolCallsArrayStart(text, keyIdx)
if !ok {
return -1, false
}
i := skipSpaces(text, arrStart+1)
if i >= len(text) || text[i] != '{' {
return -1, false
}
return i, true
}
func findToolCallsArrayStart(text string, keyIdx int) (int, bool) {
i := keyIdx + len("tool_calls")
for i < len(text) && text[i] != ':' {
i++
}
if i >= len(text) {
return -1, false
}
i = skipSpaces(text, i+1)
if i >= len(text) || text[i] != '[' {
return -1, false
}
return i, true
}
func extractToolCallName(text string, callStart int) (string, bool) {
valueStart, ok := findObjectFieldValueStart(text, callStart, []string{"name"})
if !ok || valueStart >= len(text) || text[valueStart] != '"' {
fnStart, fnOK := findFunctionObjectStart(text, callStart)
if !fnOK {
return "", false
}
valueStart, ok = findObjectFieldValueStart(text, fnStart, []string{"name"})
if !ok || valueStart >= len(text) || text[valueStart] != '"' {
return "", false
}
}
name, _, ok := parseJSONStringLiteral(text, valueStart)
if !ok {
return "", false
}
return name, true
}
func findToolCallArgsStart(text string, callStart int) (int, bool, bool) {
keys := []string{"input", "arguments", "args", "parameters", "params"}
valueStart, ok := findObjectFieldValueStart(text, callStart, keys)
if !ok {
fnStart, fnOK := findFunctionObjectStart(text, callStart)
if !fnOK {
return -1, false, false
}
valueStart, ok = findObjectFieldValueStart(text, fnStart, keys)
if !ok {
return -1, false, false
}
}
if valueStart >= len(text) {
return -1, false, false
}
ch := text[valueStart]
if ch == '{' || ch == '[' {
return valueStart, false, true
}
if ch == '"' {
return valueStart, true, true
}
return -1, false, false
}
func scanToolCallArgsProgress(text string, start int, stringMode bool) (int, bool, bool) {
if start < 0 || start > len(text) {
return 0, false, false
}
if stringMode {
escaped := false
for i := start; i < len(text); i++ {
ch := text[i]
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
return i, true, true
}
}
return len(text), false, true
}
if start >= len(text) {
return start, false, false
}
if text[start] != '{' && text[start] != '[' {
return 0, false, false
}
depth := 0
quote := byte(0)
escaped := false
for i := start; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' || ch == '[' {
depth++
continue
}
if ch == '}' || ch == ']' {
depth--
if depth == 0 {
return i + 1, true, true
}
}
}
return len(text), false, true
}
func findFunctionObjectStart(text string, callStart int) (int, bool) {
valueStart, ok := findObjectFieldValueStart(text, callStart, []string{"function"})
if !ok || valueStart >= len(text) || text[valueStart] != '{' {
return -1, false
}
return valueStart, true
}

View File

@@ -1,7 +1,5 @@
package openai
import "strings"
func extractJSONObjectFrom(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '{' {
return "", 0, false
@@ -43,110 +41,3 @@ func extractJSONObjectFrom(text string, start int) (string, int, bool) {
}
return "", 0, false
}
func findObjectFieldValueStart(text string, objStart int, keys []string) (int, bool) {
if objStart < 0 || objStart >= len(text) || text[objStart] != '{' {
return 0, false
}
depth := 0
quote := byte(0)
escaped := false
for i := objStart; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
if depth == 1 {
key, end, ok := parseJSONStringLiteral(text, i)
if !ok {
return 0, false
}
j := skipSpaces(text, end)
if j >= len(text) || text[j] != ':' {
i = end - 1
continue
}
j = skipSpaces(text, j+1)
if j >= len(text) {
return 0, false
}
if containsKey(keys, key) {
return j, true
}
i = j - 1
continue
}
quote = ch
continue
}
if ch == '{' {
depth++
continue
}
if ch == '}' {
depth--
if depth == 0 {
break
}
}
}
return 0, false
}
func parseJSONStringLiteral(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '"' {
return "", 0, false
}
var b strings.Builder
escaped := false
for i := start + 1; i < len(text); i++ {
ch := text[i]
if escaped {
b.WriteByte(ch)
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
return b.String(), i + 1, true
}
b.WriteByte(ch)
}
return "", 0, false
}
func containsKey(keys []string, value string) bool {
for _, k := range keys {
if k == value {
return true
}
}
return false
}
func skipSpaces(text string, i int) int {
for i < len(text) {
switch text[i] {
case ' ', '\t', '\n', '\r':
i++
default:
return i
}
}
return i
}

View File

@@ -63,14 +63,3 @@ func appendTail(prev, next string, max int) string {
}
return combined[len(combined)-max:]
}
func looksLikeToolExampleContext(text string) bool {
return insideCodeFence(text)
}
func insideCodeFence(text string) bool {
if text == "" {
return false
}
return strings.Count(text, "```")%2 == 1
}

View File

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

View File

@@ -36,8 +36,10 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel)
pr.Get("/vercel/status", h.vercelStatus)
pr.Post("/vercel/status", h.vercelStatus)
pr.Get("/export", h.exportConfig)
pr.Get("/dev/captures", h.getDevCaptures)
pr.Delete("/dev/captures", h.clearDevCaptures)
pr.Get("/version", h.getVersion)
})
}

View File

@@ -54,6 +54,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
}
items := make([]map[string]any, 0, end-start)
for _, acc := range accounts[start:end] {
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
token := strings.TrimSpace(acc.Token)
preview := ""
if token != "" {
@@ -70,7 +71,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"has_password": acc.Password != "",
"has_token": token != "",
"token_preview": preview,
"test_status": acc.TestStatus,
"test_status": testStatus,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -26,9 +25,9 @@ func newAdminTestHandler(t *testing.T, raw string) *Handler {
}
}
func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) {
func TestListAccountsUsesEmailIdentifier(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"token":"token-only-account"}]
"accounts":[{"email":"u@example.com","password":"pwd"}]
}`)
req := httptest.NewRequest(http.MethodGet, "/admin/accounts?page=1&page_size=10", nil)
@@ -49,38 +48,8 @@ func TestListAccountsIncludesTokenOnlyIdentifier(t *testing.T) {
}
first, _ := items[0].(map[string]any)
identifier, _ := first["identifier"].(string)
if identifier == "" {
t.Fatalf("expected non-empty identifier: %#v", first)
}
if !strings.HasPrefix(identifier, "token:") {
t.Fatalf("expected token synthetic identifier, got %q", identifier)
}
}
func TestDeleteAccountSupportsTokenOnlyIdentifier(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[{"token":"token-only-account"}]
}`)
accounts := h.Store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
id := accounts[0].Identifier()
if id == "" {
t.Fatal("expected token-only synthetic identifier")
}
r := chi.NewRouter()
r.Delete("/admin/accounts/{identifier}", h.deleteAccount)
req := httptest.NewRequest(http.MethodDelete, "/admin/accounts/"+url.PathEscape(id), nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
if got := len(h.Store.Accounts()); got != 0 {
t.Fatalf("expected account removed, remaining=%d", got)
if identifier != "u@example.com" {
t.Fatalf("expected email identifier, got %q", identifier)
}
}
@@ -142,11 +111,10 @@ func TestAddAccountRejectsCanonicalMobileDuplicate(t *testing.T) {
}
}
func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) {
func TestFindAccountByIdentifierSupportsMobile(t *testing.T) {
h := newAdminTestHandler(t, `{
"accounts":[
{"email":"u@example.com","mobile":"13800138000","password":"pwd"},
{"token":"token-only-account"}
{"email":"u@example.com","mobile":"13800138000","password":"pwd"}
]
}`)
@@ -165,21 +133,4 @@ func TestFindAccountByIdentifierSupportsMobileAndTokenOnly(t *testing.T) {
t.Fatalf("unexpected account by +86 mobile: %#v", accByMobileWithCountryCode)
}
tokenOnlyID := ""
for _, acc := range h.Store.Accounts() {
if strings.TrimSpace(acc.Email) == "" && strings.TrimSpace(acc.Mobile) == "" {
tokenOnlyID = acc.Identifier()
break
}
}
if tokenOnlyID == "" {
t.Fatal("expected token-only account identifier")
}
accByTokenOnly, ok := findAccountByIdentifier(h.Store, tokenOnlyID)
if !ok {
t.Fatalf("expected find by token-only id=%q", tokenOnlyID)
}
if accByTokenOnly.Token != "token-only-account" {
t.Fatalf("unexpected token-only account: %#v", accByTokenOnly)
}
}

View File

@@ -89,7 +89,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now()
identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0}
result := map[string]any{
"account": identifier,
"success": false,
"response_time": 0,
"message": "",
"model": model,
"session_count": 0,
"config_writable": !h.Store.IsEnvBacked(),
}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
@@ -97,15 +105,14 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
}()
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
return result
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
token, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
return result
}
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
return result
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token}
sessionID, err := h.DS.CreateSession(ctx, authCtx, 1)
@@ -117,7 +124,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
}
token = newToken
authCtx.DeepSeekToken = token
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
return result
}
sessionID, err = h.DS.CreateSession(ctx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
@@ -133,7 +143,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
if strings.TrimSpace(message) == "" {
result["success"] = true
result["message"] = "API 测试成功(仅会话创建)"
result["message"] = "Token 刷新成功(登录与会话创建成功"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
@@ -232,20 +242,16 @@ func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
return
}
// 获取 token
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
// 每次先登录刷新一次 token避免使用过期 token
token, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
// 删除所有会话
err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
err = h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(r.Context(), acc)

View File

@@ -77,7 +77,7 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Fatalf("expected success=true, got %#v", result)
}
msg, _ := result["message"].(string)
if !strings.Contains(msg, "仅会话创建") {
if !strings.Contains(msg, "Token 刷新成功") {
t.Fatalf("expected session-only success message, got %q", msg)
}
if ds.loginCalls != 1 || ds.createSessionCalls != 1 {
@@ -93,8 +93,9 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token to be persisted, got %q", updated.Token)
}
if updated.TestStatus != "ok" {
t.Fatalf("expected test status ok, got %q", updated.TestStatus)
testStatus, ok := store.AccountTestStatus("batch@example.com")
if !ok || testStatus != "ok" {
t.Fatalf("expected runtime test status ok, got %q (ok=%v)", testStatus, ok)
}
}
@@ -118,8 +119,8 @@ func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
if ok, _ := resp["success"].(bool); !ok {
t.Fatalf("expected success response, got %#v", resp)
}
if ds.loginCalls != 1 {
t.Fatalf("expected relogin once, got %d", ds.loginCalls)
if ds.loginCalls != 2 {
t.Fatalf("expected initial login plus relogin, got %d", ds.loginCalls)
}
if ds.deleteAllSessionsCalls != 2 {
t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls)

View File

@@ -43,6 +43,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
incoming.ClearAccountTokens()
importedKeys, importedAccounts := 0, 0
err = h.Store.Update(func(c *config.Config) error {
@@ -180,6 +181,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
func (h *Handler) computeSyncHash() string {
snap := h.Store.Snapshot().Clone()
snap.ClearAccountTokens()
snap.VercelSyncHash = ""
snap.VercelSyncTime = 0
b, _ := json.Marshal(snap)

View File

@@ -8,8 +8,9 @@ import (
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
snap := h.Store.Snapshot()
safe := map[string]any{
"keys": snap.Keys,
"accounts": []map[string]any{},
"keys": snap.Keys,
"accounts": []map[string]any{},
"env_backed": h.Store.IsEnvBacked(),
"claude_mapping": func() map[string]string {
if len(snap.ClaudeMapping) > 0 {
return snap.ClaudeMapping

View File

@@ -50,9 +50,6 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(acc.Password) == "" {
acc.Password = prev.Password
}
if strings.TrimSpace(acc.Token) == "" {
acc.Token = prev.Token
}
}
seen[key] = struct{}{}
accounts = append(accounts, acc)

View File

@@ -3,6 +3,8 @@ package admin
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -11,6 +13,8 @@ import (
"os"
"strings"
"time"
"ds2api/internal/config"
)
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
@@ -25,7 +29,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
return
}
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
_, cfgB64, err := h.Store.ExportJSONAndBase64()
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
@@ -47,7 +51,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(h.computeSyncHash(), time.Now().Unix())
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
if manual {
result["message"] = "配置已同步到 Vercel请手动触发重新部署"
@@ -209,11 +213,71 @@ func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID
return false, deployURL
}
func (h *Handler) vercelStatus(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
snap := h.Store.Snapshot()
current := h.computeSyncHash()
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
writeJSON(w, http.StatusOK, map[string]any{"synced": synced, "last_sync_time": nilIfZero(snap.VercelSyncTime), "has_synced_before": snap.VercelSyncHash != ""})
draftHash := ""
draftDiffers := false
if r != nil && r.Method == http.MethodPost && r.Body != nil {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
if cfgJSON, _, err := h.exportSyncConfig(req); err == nil {
draftHash = syncHashForJSON(cfgJSON)
draftDiffers = draftHash != "" && draftHash != current
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"synced": synced,
"last_sync_time": nilIfZero(snap.VercelSyncTime),
"has_synced_before": snap.VercelSyncHash != "",
"env_backed": h.Store.IsEnvBacked(),
"config_hash": current,
"last_synced_hash": snap.VercelSyncHash,
"draft_hash": draftHash,
"draft_differs": draftDiffers,
})
}
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
return h.Store.ExportJSONAndBase64()
}
raw, err := json.Marshal(override)
if err != nil {
return "", "", err
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
if err != nil {
return "", "", err
}
return string(b), base64.StdEncoding.EncodeToString(b), nil
}
func syncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {

View File

@@ -0,0 +1,75 @@
package admin
import (
"encoding/json"
"net/http"
"strings"
"time"
"ds2api/internal/version"
)
const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest"
type latestReleasePayload struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
PublishedAt string `json:"published_at"`
}
func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
current, source := version.Current()
resp := map[string]any{
"success": true,
"current_version": current,
"current_tag": version.Tag(current),
"source": source,
"checked_at": time.Now().UTC().Format(time.RFC3339),
}
req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "ds2api-version-check")
client := &http.Client{Timeout: 4 * time.Second}
r, err := client.Do(req)
if err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
defer r.Body.Close()
if r.StatusCode < 200 || r.StatusCode >= 300 {
resp["check_error"] = "github api status: " + r.Status
writeJSON(w, http.StatusOK, resp)
return
}
var data latestReleasePayload
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
resp["check_error"] = err.Error()
writeJSON(w, http.StatusOK, resp)
return
}
latest := strings.TrimSpace(data.TagName)
if latest == "" {
resp["check_error"] = "missing latest tag"
writeJSON(w, http.StatusOK, resp)
return
}
latestVersion := strings.TrimPrefix(latest, "v")
resp["latest_tag"] = latest
resp["latest_version"] = latestVersion
resp["release_url"] = data.HTMLURL
resp["published_at"] = data.PublishedAt
resp["has_update"] = version.Compare(current, latestVersion) < 0
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -65,7 +65,6 @@ func toAccount(m map[string]any) config.Account {
Email: email,
Mobile: mobile,
Password: fieldString(m, "password"),
Token: fieldString(m, "token"),
}
}

View File

@@ -188,8 +188,8 @@ func TestToAccountAllFields(t *testing.T) {
if acc.Password != "secret" {
t.Fatalf("unexpected password: %q", acc.Password)
}
if acc.Token != "tok123" {
t.Fatalf("unexpected token: %q", acc.Token)
if acc.Token != "" {
t.Fatalf("expected token to be ignored, got %q", acc.Token)
}
}

View File

@@ -0,0 +1,109 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"ds2api/internal/account"
"ds2api/internal/config"
)
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds DeepSeekCaller) http.Handler {
t.Helper()
t.Setenv("DS2API_CONFIG_JSON", rawConfig)
t.Setenv("CONFIG_JSON", "")
store := config.LoadStore()
h := &Handler{
Store: store,
Pool: account.NewPool(store),
DS: ds,
}
r := chi.NewRouter()
RegisterRoutes(r, h)
return r
}
func adminReq(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer admin")
req.Header.Set("Content-Type", "application/json")
return req
}
func TestConfigImportIgnoresTokenFieldInPayload(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{"accounts":[]}`, ds)
payload := []byte(`{
"mode":"replace",
"config":{
"accounts":[{"email":"u@example.com","password":"pwd","token":"expired-token"}]
}
}`)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/config/import", payload))
if rec.Code != http.StatusOK {
t.Fatalf("import status=%d body=%s", rec.Code, rec.Body.String())
}
readRec := httptest.NewRecorder()
router.ServeHTTP(readRec, adminReq(http.MethodGet, "/config", nil))
if readRec.Code != http.StatusOK {
t.Fatalf("get config status=%d body=%s", readRec.Code, readRec.Body.String())
}
var data map[string]any
if err := json.Unmarshal(readRec.Body.Bytes(), &data); err != nil {
t.Fatalf("decode config response: %v", err)
}
accounts, _ := data["accounts"].([]any)
if len(accounts) != 1 {
t.Fatalf("expected one account, got %d", len(accounts))
}
accountMap, _ := accounts[0].(map[string]any)
if hasToken, _ := accountMap["has_token"].(bool); hasToken {
t.Fatalf("expected imported token to be ignored, account=%#v", accountMap)
}
}
func TestAccountTestRefreshesRuntimeTokenButExportOmitsToken(t *testing.T) {
ds := &testingDSMock{}
router := newHTTPAdminHarness(t, `{
"accounts":[{"email":"batch@example.com","password":"pwd","token":"stale-token"}]
}`, ds)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminReq(http.MethodPost, "/accounts/test", []byte(`{"identifier":"batch@example.com"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("test account status=%d body=%s", rec.Code, rec.Body.String())
}
var testResp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &testResp); err != nil {
t.Fatalf("decode test response: %v", err)
}
if ok, _ := testResp["success"].(bool); !ok {
t.Fatalf("expected test success, got %#v", testResp)
}
if ds.loginCalls < 1 {
t.Fatalf("expected login to be called at least once, got %d", ds.loginCalls)
}
exportRec := httptest.NewRecorder()
router.ServeHTTP(exportRec, adminReq(http.MethodGet, "/config/export", nil))
if exportRec.Code != http.StatusOK {
t.Fatalf("export status=%d body=%s", exportRec.Code, exportRec.Body.String())
}
var exportResp map[string]any
if err := json.Unmarshal(exportRec.Body.Bytes(), &exportResp); err != nil {
t.Fatalf("decode export response: %v", err)
}
exportJSON, _ := exportResp["json"].(string)
if strings.Contains(exportJSON, `"token"`) {
t.Fatalf("expected export json to omit tokens, got %s", exportJSON)
}
}

View File

@@ -7,6 +7,8 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"ds2api/internal/account"
"ds2api/internal/config"
@@ -37,10 +39,20 @@ type Resolver struct {
Store *config.Store
Pool *account.Pool
Login LoginFunc
mu sync.Mutex
tokenRefreshedAt map[string]time.Time
tokenRefreshInterval time.Duration
}
func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver {
return &Resolver{Store: store, Pool: pool, Login: login}
return &Resolver{
Store: store,
Pool: pool,
Login: login,
tokenRefreshedAt: map[string]time.Time{},
tokenRefreshInterval: 6 * time.Hour,
}
}
func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
@@ -72,13 +84,9 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
TriedAccounts: map[string]bool{},
resolver: r,
}
if acc.Token == "" {
if err := r.loginAndPersist(ctx, a); err != nil {
r.Pool.Release(a.AccountID)
return nil, err
}
} else {
a.DeepSeekToken = acc.Token
if err := r.ensureManagedToken(ctx, a); err != nil {
r.Pool.Release(a.AccountID)
return nil, err
}
return a, nil
}
@@ -120,6 +128,7 @@ func (r *Resolver) loginAndPersist(ctx context.Context, a *RequestAuth) error {
}
a.Account.Token = token
a.DeepSeekToken = token
r.markTokenRefreshedNow(a.AccountID)
return r.Store.UpdateAccountToken(a.AccountID, token)
}
@@ -142,6 +151,7 @@ func (r *Resolver) MarkTokenInvalid(a *RequestAuth) {
}
a.Account.Token = ""
a.DeepSeekToken = ""
r.clearTokenRefreshMark(a.AccountID)
_ = r.Store.UpdateAccountToken(a.AccountID, "")
}
@@ -162,12 +172,8 @@ func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
}
a.Account = acc
a.AccountID = acc.Identifier()
if acc.Token == "" {
if err := r.loginAndPersist(ctx, a); err != nil {
return false
}
} else {
a.DeepSeekToken = acc.Token
if err := r.ensureManagedToken(ctx, a); err != nil {
return false
}
return true
}
@@ -210,3 +216,53 @@ func callerTokenID(token string) string {
sum := sha256.Sum256([]byte(token))
return "caller:" + hex.EncodeToString(sum[:8])
}
func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error {
if strings.TrimSpace(a.Account.Token) == "" {
return r.loginAndPersist(ctx, a)
}
if r.shouldForceRefresh(a.AccountID) {
if err := r.loginAndPersist(ctx, a); err != nil {
return err
}
return nil
}
a.DeepSeekToken = a.Account.Token
return nil
}
func (r *Resolver) shouldForceRefresh(accountID string) bool {
if strings.TrimSpace(accountID) == "" {
return false
}
if r.tokenRefreshInterval <= 0 {
return false
}
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
last, ok := r.tokenRefreshedAt[accountID]
if !ok || last.IsZero() {
r.tokenRefreshedAt[accountID] = now
return false
}
return now.Sub(last) >= r.tokenRefreshInterval
}
func (r *Resolver) markTokenRefreshedNow(accountID string) {
if strings.TrimSpace(accountID) == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.tokenRefreshedAt[accountID] = time.Now()
}
func (r *Resolver) clearTokenRefreshMark(accountID string) {
if strings.TrimSpace(accountID) == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tokenRefreshedAt, accountID)
}

View File

@@ -3,7 +3,9 @@ package auth
import (
"context"
"net/http"
"sync/atomic"
"testing"
"time"
"ds2api/internal/account"
"ds2api/internal/config"
@@ -58,7 +60,7 @@ func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) {
if auth.AccountID != "acc@example.com" {
t.Fatalf("unexpected account id: %q", auth.AccountID)
}
if auth.DeepSeekToken != "account-token" {
if auth.DeepSeekToken != "fresh-token" {
t.Fatalf("unexpected account token: %q", auth.DeepSeekToken)
}
if auth.CallerID == "" {
@@ -193,3 +195,52 @@ func TestDetermineCallerMissingToken(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDetermineManagedAccountForcesRefreshEverySixHours(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[{"email":"acc@example.com","password":"pwd","token":"seed-token"}]
}`)
store := config.LoadStore()
if err := store.UpdateAccountToken("acc@example.com", "seed-token"); err != nil {
t.Fatalf("update token failed: %v", err)
}
pool := account.NewPool(store)
var loginCount int32
resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
n := atomic.AddInt32(&loginCount, 1)
return "fresh-token-" + string(rune('0'+n)), nil
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
a1, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine failed: %v", err)
}
if a1.DeepSeekToken != "seed-token" {
t.Fatalf("expected initial token without forced refresh, got %q", a1.DeepSeekToken)
}
resolver.Release(a1)
if got := atomic.LoadInt32(&loginCount); got != 0 {
t.Fatalf("expected no login before refresh interval, got %d", got)
}
resolver.mu.Lock()
resolver.tokenRefreshedAt["acc@example.com"] = time.Now().Add(-7 * time.Hour)
resolver.mu.Unlock()
a2, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine after interval failed: %v", err)
}
defer resolver.Release(a2)
if a2.DeepSeekToken != "fresh-token-1" {
t.Fatalf("expected refreshed token after interval, got %q", a2.DeepSeekToken)
}
if got := atomic.LoadInt32(&loginCount); got != 1 {
t.Fatalf("expected exactly one forced refresh login, got %d", got)
}
}

View File

@@ -1,10 +1,6 @@
package config
import (
"crypto/sha256"
"encoding/hex"
"strings"
)
import "strings"
func (a Account) Identifier() string {
if strings.TrimSpace(a.Email) != "" {
@@ -13,12 +9,5 @@ func (a Account) Identifier() string {
if mobile := NormalizeMobileForStorage(a.Mobile); mobile != "" {
return mobile
}
// Backward compatibility: old configs may contain token-only accounts.
// Use a stable non-sensitive synthetic id so they can still join the pool.
token := strings.TrimSpace(a.Token)
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return "token:" + hex.EncodeToString(sum[:8])
return ""
}

View File

@@ -12,18 +12,43 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
}
type Account struct {
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TestStatus string `json:"test_status,omitempty"`
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
}
func (c *Config) ClearAccountTokens() {
if c == nil {
return
}
for i := range c.Accounts {
c.Accounts[i].Token = ""
}
}
// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
// (no email and no normalizable mobile). This prevents legacy token-only
// records from becoming orphaned empty entries after token stripping.
func (c *Config) DropInvalidAccounts() {
if c == nil || len(c.Accounts) == 0 {
return
}
kept := make([]Account, 0, len(c.Accounts))
for _, acc := range c.Accounts {
if acc.Identifier() == "" {
continue
}
kept = append(kept, acc)
}
c.Accounts = kept
}
type CompatConfig struct {

View File

@@ -2,25 +2,23 @@ package config
import (
"encoding/base64"
"os"
"strings"
"testing"
)
func TestAccountIdentifierFallsBackToTokenHash(t *testing.T) {
func TestAccountIdentifierRequiresEmailOrMobile(t *testing.T) {
acc := Account{Token: "example-token-value"}
id := acc.Identifier()
if !strings.HasPrefix(id, "token:") {
t.Fatalf("expected token-prefixed identifier, got %q", id)
}
if len(id) != len("token:")+16 {
t.Fatalf("unexpected identifier length: %d (%q)", len(id), id)
if id != "" {
t.Fatalf("expected empty identifier when only token is present, got %q", id)
}
}
func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) {
func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"token":"token-only-account"}]
"accounts":[{"email":"u@example.com","password":"p","token":"token-only-account"}]
}`)
store := LoadStore()
@@ -28,22 +26,62 @@ func TestStoreFindAccountWithTokenOnlyIdentifier(t *testing.T) {
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
id := accounts[0].Identifier()
if id == "" {
t.Fatalf("expected synthetic identifier for token-only account")
}
found, ok := store.FindAccount(id)
if !ok {
t.Fatalf("expected FindAccount to locate token-only account by synthetic id")
}
if found.Token != "token-only-account" {
t.Fatalf("unexpected token value: %q", found.Token)
if accounts[0].Token != "" {
t.Fatalf("expected token to be cleared after loading, got %q", accounts[0].Token)
}
}
func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T) {
func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"token":"old-token"}]
"accounts":[
{"token":"legacy-token-only"},
{"email":"u@example.com","password":"p","token":"runtime-token"}
]
}`)
store := LoadStore()
accounts := store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected token-only account to be dropped, got %d accounts", len(accounts))
}
if accounts[0].Identifier() != "u@example.com" {
t.Fatalf("unexpected remaining account: %#v", accounts[0])
}
if accounts[0].Token != "" {
t.Fatalf("expected persisted token to be cleared, got %q", accounts[0].Token)
}
}
func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","token":"persisted-token"}]
}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
t.Setenv("DS2API_CONFIG_JSON", "")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
store := LoadStore()
accounts := store.Accounts()
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
if accounts[0].Token != "persisted-token" {
t.Fatalf("expected file-backed token preserved for runtime use, got %q", accounts[0].Token)
}
}
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"email":"user@example.com","password":"p"}]
}`)
store := LoadStore()
@@ -52,23 +90,12 @@ func TestStoreUpdateAccountTokenKeepsOldAndNewIdentifierResolvable(t *testing.T)
t.Fatalf("expected 1 account, got %d", len(before))
}
oldID := before[0].Identifier()
if oldID == "" {
t.Fatal("expected old identifier")
}
if err := store.UpdateAccountToken(oldID, "new-token"); err != nil {
t.Fatalf("update token failed: %v", err)
}
after := store.Accounts()
newID := after[0].Identifier()
if newID == "" || newID == oldID {
t.Fatalf("expected changed identifier, old=%q new=%q", oldID, newID)
}
if got, ok := store.FindAccount(newID); !ok || got.Token != "new-token" {
t.Fatalf("expected find by new identifier")
}
if got, ok := store.FindAccount(oldID); !ok || got.Token != "new-token" {
t.Fatalf("expected find by old identifier alias")
t.Fatalf("expected find by stable account identifier")
}
}
@@ -121,3 +148,39 @@ func TestLoadConfigOnVercelWithoutConfigFileFallsBackToMemory(t *testing.T) {
t.Fatalf("expected empty bootstrap config, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts))
}
}
func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer tmp.Close()
if _, err := tmp.WriteString(`{
"accounts":[{"email":"u@example.com","password":"p","test_status":"ok"}]
}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
t.Setenv("DS2API_CONFIG_JSON", "")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
store := LoadStore()
if got, ok := store.AccountTestStatus("u@example.com"); ok || got != "" {
t.Fatalf("expected no runtime status loaded from config, got %q", got)
}
if err := store.UpdateAccountTestStatus("u@example.com", "ok"); err != nil {
t.Fatalf("update test status: %v", err)
}
if got, ok := store.AccountTestStatus("u@example.com"); !ok || got != "ok" {
t.Fatalf("expected runtime status to be available, got %q (ok=%v)", got, ok)
}
content, err := os.ReadFile(tmp.Name())
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(content), "test_status") {
t.Fatalf("expected test_status to stay out of persisted config, got: %s", content)
}
}

View File

@@ -17,6 +17,7 @@ type Store struct {
fromEnv bool
keyMap map[string]struct{} // O(1) API key lookup index
accMap map[string]int // O(1) account lookup: identifier -> slice index
accTest map[string]string // runtime-only account test status cache
}
func LoadStore() *Store {
@@ -39,6 +40,8 @@ func loadConfig() (Config, bool, error) {
}
if rawCfg != "" {
cfg, err := parseConfigString(rawCfg)
cfg.ClearAccountTokens()
cfg.DropInvalidAccounts()
return cfg, true, err
}
@@ -55,6 +58,12 @@ func loadConfig() (Config, bool, error) {
if err := json.Unmarshal(content, &cfg); err != nil {
return Config{}, false, err
}
cfg.DropInvalidAccounts()
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
_ = os.WriteFile(ConfigPath(), b, 0o644)
}
}
if IsVercel() {
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
return cfg, true, nil
@@ -105,8 +114,19 @@ func (s *Store) UpdateAccountTestStatus(identifier, status string) error {
if !ok {
return errors.New("account not found")
}
s.cfg.Accounts[idx].TestStatus = status
return s.saveLocked()
s.setAccountTestStatusLocked(s.cfg.Accounts[idx], status, identifier)
return nil
}
func (s *Store) AccountTestStatus(identifier string) (string, bool) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return "", false
}
s.mu.RLock()
defer s.mu.RUnlock()
status, ok := s.accTest[identifier]
return status, ok
}
func (s *Store) UpdateAccountToken(identifier, token string) error {
@@ -161,7 +181,9 @@ func (s *Store) Save() error {
Logger.Info("[save_config] source from env, skip write")
return nil
}
b, err := json.MarshalIndent(s.cfg, "", " ")
persistCfg := s.cfg.Clone()
persistCfg.ClearAccountTokens()
b, err := json.MarshalIndent(persistCfg, "", " ")
if err != nil {
return err
}
@@ -173,7 +195,9 @@ func (s *Store) saveLocked() error {
Logger.Info("[save_config] source from env, skip write")
return nil
}
b, err := json.MarshalIndent(s.cfg, "", " ")
persistCfg := s.cfg.Clone()
persistCfg.ClearAccountTokens()
b, err := json.MarshalIndent(persistCfg, "", " ")
if err != nil {
return err
}
@@ -197,7 +221,9 @@ func (s *Store) SetVercelSync(hash string, ts int64) error {
func (s *Store) ExportJSONAndBase64() (string, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
b, err := json.Marshal(s.cfg)
exportCfg := s.cfg.Clone()
exportCfg.ClearAccountTokens()
b, err := json.Marshal(exportCfg)
if err != nil {
return "", "", err
}

View File

@@ -2,15 +2,20 @@ package config
// rebuildIndexes must be called with the lock already held (or during init).
func (s *Store) rebuildIndexes() {
prevStatus := s.accTest
s.keyMap = make(map[string]struct{}, len(s.cfg.Keys))
for _, k := range s.cfg.Keys {
s.keyMap[k] = struct{}{}
}
s.accMap = make(map[string]int, len(s.cfg.Accounts))
s.accTest = make(map[string]string, len(s.cfg.Accounts))
for i, acc := range s.cfg.Accounts {
id := acc.Identifier()
if id != "" {
s.accMap[id] = i
if status, ok := prevStatus[id]; ok {
s.setAccountTestStatusLocked(acc, status, "")
}
}
}
}
@@ -29,3 +34,22 @@ func (s *Store) findAccountIndexLocked(identifier string) (int, bool) {
}
return -1, false
}
func (s *Store) setAccountTestStatusLocked(acc Account, status, hintedIdentifier string) {
status = lower(status)
if status == "" {
return
}
if id := acc.Identifier(); id != "" {
s.accTest[id] = status
}
if email := acc.Email; email != "" {
s.accTest[email] = status
}
if mobile := CanonicalMobileKey(acc.Mobile); mobile != "" {
s.accTest[mobile] = status
}
if hintedIdentifier = lower(hintedIdentifier); hintedIdentifier != "" {
s.accTest[hintedIdentifier] = status
}
}

View File

@@ -73,7 +73,7 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
}
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
@@ -118,7 +118,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
}
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
@@ -160,6 +160,47 @@ func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string
strings.Contains(msg, "invalid jwt")
}
func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return true
}
// Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code.
// Only attempt refresh when these biz failures still look auth-related.
return status == http.StatusOK &&
code == 0 &&
bizCode != 0 &&
isAuthIndicativeBizFailure(msg, bizMsg)
}
func isAuthIndicativeBizFailure(msg string, bizMsg string) bool {
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
authKeywords := []string{
"auth",
"authorization",
"credential",
"expired",
"invalid jwt",
"jwt",
"login",
"not login",
"session expired",
"token",
"unauthorized",
"登录",
"未登录",
"认证",
"凭证",
"会话过期",
"令牌",
}
for _, keyword := range authKeywords {
if strings.Contains(combined, keyword) {
return true
}
}
return false
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)

View File

@@ -0,0 +1,27 @@
package deepseek
import "testing"
func TestShouldAttemptRefreshOnTokenInvalidSignal(t *testing.T) {
if !shouldAttemptRefresh(401, 0, 0, "unauthorized", "") {
t.Fatal("expected refresh when response indicates invalid token")
}
}
func TestShouldAttemptRefreshOnAuthIndicativeBizCodeFailure(t *testing.T) {
if !shouldAttemptRefresh(200, 0, 400123, "", "login expired, token invalid") {
t.Fatal("expected refresh on auth-indicative biz_code failure")
}
}
func TestShouldAttemptRefreshFalseOnNonAuthBizCodeFailure(t *testing.T) {
if shouldAttemptRefresh(200, 0, 400123, "", "session create failed: quota reached") {
t.Fatal("did not expect refresh on non-auth biz_code failure")
}
}
func TestShouldAttemptRefreshFalseOnGenericServerError(t *testing.T) {
if shouldAttemptRefresh(500, 500, 0, "internal error", "") {
t.Fatal("did not expect refresh on generic server error")
}
}

View File

@@ -63,17 +63,6 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
return out, resp.StatusCode, nil
}
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {

View File

@@ -2,6 +2,7 @@ package openai
import (
"encoding/json"
"strings"
"testing"
)
@@ -69,7 +70,7 @@ func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testi
}
}
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
func TestBuildResponseObjectKeepsFencedToolPayloadAsText(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
@@ -80,19 +81,24 @@ func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T
)
outputText, _ := obj["output_text"].(string)
if outputText != "" {
t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
if !strings.Contains(outputText, "\"tool_calls\"") {
t.Fatalf("expected output_text to preserve fenced tool payload, got %q", outputText)
}
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
t.Fatalf("expected one message output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "function_call" {
t.Fatalf("expected function_call output type, got %#v", first["type"])
if first["type"] != "message" {
t.Fatalf("expected message output type, got %#v", first["type"])
}
}
// Backward-compatible alias for historical test name used in CI logs.
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
TestBuildResponseObjectKeepsFencedToolPayloadAsText(t)
}
func TestBuildResponseObjectReasoningOnlyFallsBackToOutputText(t *testing.T) {
obj := BuildResponseObject(
"resp_test",

View File

@@ -10,10 +10,10 @@ function resolveToolcallPolicy(prepBody, payloadTools) {
const preparedToolNames = normalizePreparedToolNames(prepBody && prepBody.tool_names);
const toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match);
const emitEarlyToolDeltas = boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
return {
toolNames,
toolSieveEnabled: toolNames.length > 0 && featureMatchEnabled,
toolSieveEnabled: toolNames.length > 0,
emitEarlyToolDeltas,
};
}
@@ -60,6 +60,9 @@ function formatIncrementalToolCallDeltas(deltas, idStore) {
if (typeof d.arguments === 'string' && d.arguments !== '') {
fn.arguments = d.arguments;
}
if (Object.keys(fn).length === 0) {
continue;
}
if (Object.keys(fn).length > 0) {
item.function = fn;
}

View File

@@ -8,24 +8,30 @@ const {
parseToolCallsPayload,
parseMarkupToolCalls,
parseTextKVToolCalls,
stripFencedCodeBlocks,
} = require('./parse_payload');
const { TOOL_SEGMENT_KEYWORDS } = require('./tool-keywords');
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
const TOOL_MARKUP_PREFIXES = ['<tool_call', '<function_call', '<invoke'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {
return [];
}
const out = [];
const seen = new Set();
for (const t of tools) {
if (!t || typeof t !== 'object') {
continue;
}
const fn = t.function && typeof t.function === 'object' ? t.function : t;
const name = toStringSafe(fn.name);
// Keep parity with Go injectToolPrompt: object tools without name still
// enter tool mode via fallback name "unknown".
out.push(name || 'unknown');
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
out.push(name);
}
return out;
}
@@ -41,6 +47,9 @@ function parseToolCallsDetailed(text, toolNames) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) {
return result;
}
const candidates = buildToolCallCandidates(normalized);
let parsed = [];
@@ -86,6 +95,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) {
return result;
}
const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
@@ -220,11 +232,16 @@ function resolveAllowedToolName(name, allowed, allowedCanonical) {
function looksLikeToolCallSyntax(text) {
const lower = toStringSafe(text).toLowerCase();
return lower.includes('tool_calls')
|| lower.includes('<tool_call')
|| lower.includes('<function_call')
|| lower.includes('<invoke')
|| lower.includes('function.name:');
return TOOL_SEGMENT_KEYWORDS.some((kw) => lower.includes(kw))
|| TOOL_MARKUP_PREFIXES.some((prefix) => lower.includes(prefix));
}
function shouldSkipToolCallParsingForCodeFenceExample(text) {
if (!looksLikeToolCallSyntax(text)) {
return false;
}
const stripped = stripFencedCodeBlocks(text);
return !looksLikeToolCallSyntax(stripped);
}
module.exports = {

View File

@@ -114,6 +114,9 @@ function parseToolCallsPayload(payload) {
return [];
}
if (decoded.tool_calls) {
if (isLikelyChatMessageEnvelope(decoded)) {
return [];
}
return parseToolCallList(decoded.tool_calls);
}
@@ -121,6 +124,21 @@ function parseToolCallsPayload(payload) {
return one ? [one] : [];
}
function isLikelyChatMessageEnvelope(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
if (!Object.prototype.hasOwnProperty.call(value, 'tool_calls')) {
return false;
}
const role = toStringSafe(value.role).trim().toLowerCase();
if (role === 'assistant' || role === 'tool' || role === 'user' || role === 'system') {
return true;
}
return Object.prototype.hasOwnProperty.call(value, 'tool_call_id')
|| Object.prototype.hasOwnProperty.call(value, 'content');
}
function parseMarkupToolCalls(text) {
const raw = toStringSafe(text).trim();
if (!raw) {

View File

@@ -1,17 +1,12 @@
'use strict';
const {
resetIncrementalToolState,
noteText,
insideCodeFence,
insideCodeFenceWithState,
} = require('./state');
const {
parseStandaloneToolCallsDetailed,
} = require('./parse');
const {
extractJSONObjectFrom,
} = require('./jsonscan');
const { parseStandaloneToolCallsDetailed } = require('./parse');
const { extractJSONObjectFrom } = require('./jsonscan');
const { TOOL_SEGMENT_KEYWORDS, earliestKeywordIndex } = require('./tool-keywords');
function processToolSieveChunk(state, chunk, toolNames) {
if (!state) {
return [];
@@ -20,8 +15,6 @@ function processToolSieveChunk(state, chunk, toolNames) {
state.pending += chunk;
}
const events = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
@@ -60,13 +53,11 @@ function processToolSieveChunk(state, chunk, toolNames) {
}
continue;
}
const pending = state.pending || '';
if (!pending) {
break;
}
const start = findToolSegmentStart(pending);
const start = findToolSegmentStart(state, pending);
if (start >= 0) {
const prefix = pending.slice(0, start);
if (prefix) {
@@ -79,7 +70,6 @@ function processToolSieveChunk(state, chunk, toolNames) {
resetIncrementalToolState(state);
continue;
}
const [safe, hold] = splitSafeContentForToolDetection(pending);
if (!safe) {
break;
@@ -96,13 +86,11 @@ function flushToolSieve(state, toolNames) {
return [];
}
const events = processToolSieveChunk(state, '', toolNames);
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
events.push({ type: 'tool_calls', calls: state.pendingToolCalls });
state.pendingToolRaw = '';
state.pendingToolCalls = [];
}
if (state.capturing) {
const consumed = consumeToolCapture(state, toolNames);
if (consumed.ready) {
@@ -125,13 +113,11 @@ function flushToolSieve(state, toolNames) {
state.capturing = false;
resetIncrementalToolState(state);
}
if (state.pending) {
noteText(state, state.pending);
events.push({ type: 'text', text: state.pending });
state.pending = '';
}
return events;
}
@@ -147,8 +133,6 @@ function splitSafeContentForToolDetection(s) {
if (suspiciousStart > 0) {
return [text.slice(0, suspiciousStart), text.slice(suspiciousStart)];
}
// If suspicious content starts at the beginning, keep holding until we can
// either parse a full tool JSON block or reach stream flush.
return ['', text];
}
@@ -163,36 +147,21 @@ function findSuspiciousPrefixStart(s) {
return start;
}
function findToolSegmentStart(s) {
function findToolSegmentStart(state, s) {
if (!s) {
return -1;
}
const lower = s.toLowerCase();
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
let offset = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
let bestKeyIdx = -1;
let matchedKeyword = '';
for (const kw of keywords) {
const idx = lower.indexOf(kw, offset);
if (idx >= 0) {
if (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx;
matchedKeyword = kw;
}
}
}
const { index: bestKeyIdx, keyword: matchedKeyword } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS, offset);
if (bestKeyIdx < 0) {
return -1;
}
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx;
if (!insideCodeFence(s.slice(0, candidateStart))) {
if (!insideCodeFenceWithState(state, s.slice(0, candidateStart))) {
return candidateStart;
}
offset = keyIdx + matchedKeyword.length;
@@ -205,31 +174,30 @@ function consumeToolCapture(state, toolNames) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const lower = captured.toLowerCase();
let keyIdx = -1;
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
for (const kw of keywords) {
const idx = lower.indexOf(kw);
if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) {
keyIdx = idx;
}
}
const { index: keyIdx } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS);
if (keyIdx < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const start = captured.slice(0, keyIdx).lastIndexOf('{');
const actualStart = start >= 0 ? start : keyIdx;
if (start < 0) {
const history = extractToolHistoryBlock(captured, keyIdx);
if (history.ok) {
return {
ready: true,
prefix: captured.slice(0, history.start),
calls: [],
suffix: captured.slice(history.end),
};
}
}
const obj = extractJSONObjectFrom(captured, actualStart);
if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const prefixPart = captured.slice(0, actualStart);
const suffixPart = captured.slice(obj.end);
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
if (insideCodeFenceWithState(state, prefixPart)) {
return {
ready: true,
prefix: captured,
@@ -237,7 +205,6 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
@@ -255,15 +222,61 @@ function consumeToolCapture(state, toolNames) {
suffix: '',
};
}
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: prefixPart,
prefix: trimmedFence.prefix,
calls: parsed.calls,
suffix: suffixPart,
suffix: trimmedFence.suffix,
};
}
function extractToolHistoryBlock(captured, keyIdx) {
if (typeof captured !== 'string' || keyIdx < 0 || keyIdx >= captured.length) {
return { ok: false, start: 0, end: 0 };
}
const rest = captured.slice(keyIdx).toLowerCase();
if (rest.startsWith('[tool_call_history]')) {
const closeTag = '[/tool_call_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
if (rest.startsWith('[tool_result_history]')) {
const closeTag = '[/tool_result_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
return { ok: false, start: 0, end: 0 };
}
function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
if (fenceIdx < 0) return { prefix, suffix };
const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length;
if (fenceCount % 2 === 0) {
return { prefix, suffix };
}
const header = rightTrimmedPrefix.slice(fenceIdx + 3).trim().toLowerCase();
if (header && header !== 'json') {
return { prefix, suffix };
}
const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, '');
if (!leftTrimmedSuffix.startsWith('```')) {
return { prefix, suffix };
}
const consumed = (suffix || '').length - leftTrimmedSuffix.length;
return {
prefix: rightTrimmedPrefix.slice(0, fenceIdx),
suffix: (suffix || '').slice(consumed + 3),
};
}
module.exports = {
processToolSieveChunk,
flushToolSieve,

View File

@@ -1,6 +1,6 @@
'use strict';
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 256;
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096;
function createToolSieveState() {
return {
@@ -8,6 +8,9 @@ function createToolSieveState() {
capture: '',
capturing: false,
recentTextTail: '',
codeFenceStack: [],
codeFencePendingTicks: 0,
codeFenceLineStart: true,
pendingToolRaw: '',
pendingToolCalls: [],
disableDeltas: false,
@@ -34,6 +37,7 @@ function noteText(state, text) {
if (!state || !hasMeaningfulText(text)) {
return;
}
updateCodeFenceState(state, text);
state.recentTextTail = appendTail(state.recentTextTail, text, TOOL_SIEVE_CONTEXT_TAIL_LIMIT);
}
@@ -63,6 +67,91 @@ function insideCodeFence(text) {
return ticks % 2 === 1;
}
function insideCodeFenceWithState(state, text) {
if (!state) {
return insideCodeFence(text);
}
const simulated = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
state.codeFenceLineStart !== false,
text,
);
return simulated.stack.length > 0;
}
function updateCodeFenceState(state, text) {
if (!state) {
return;
}
const next = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
state.codeFenceLineStart !== false,
text,
);
state.codeFenceStack = next.stack;
state.codeFencePendingTicks = next.pendingTicks;
state.codeFenceLineStart = next.lineStart;
}
function simulateCodeFenceState(stack, pendingTicks, lineStart, text) {
const chunk = typeof text === 'string' ? text : '';
const nextStack = Array.isArray(stack) ? [...stack] : [];
let ticks = Number.isInteger(pendingTicks) ? pendingTicks : 0;
let atLineStart = lineStart !== false;
const flushTicks = () => {
if (ticks > 0) {
if (atLineStart && ticks >= 3) {
applyFenceMarker(nextStack, ticks);
}
atLineStart = false;
ticks = 0;
}
};
for (let i = 0; i < chunk.length; i += 1) {
const ch = chunk[i];
if (ch === '`') {
ticks += 1;
continue;
}
flushTicks();
if (ch === '\n' || ch === '\r') {
atLineStart = true;
continue;
}
if ((ch === ' ' || ch === '\t') && atLineStart) {
continue;
}
atLineStart = false;
}
// keep ticks for cross-chunk continuation.
return {
stack: nextStack,
pendingTicks: ticks,
lineStart: atLineStart,
};
}
function applyFenceMarker(stack, ticks) {
if (!Array.isArray(stack)) {
return;
}
if (stack.length === 0) {
stack.push(ticks);
return;
}
const top = stack[stack.length - 1];
if (ticks >= top) {
stack.pop();
return;
}
// nested/open inner fence using longer marker for robustness.
stack.push(ticks);
}
function hasMeaningfulText(text) {
return toStringSafe(text) !== '';
}
@@ -88,6 +177,8 @@ module.exports = {
appendTail,
looksLikeToolExampleContext,
insideCodeFence,
insideCodeFenceWithState,
updateCodeFenceState,
hasMeaningfulText,
toStringSafe,
};

View File

@@ -0,0 +1,29 @@
'use strict';
const TOOL_SEGMENT_KEYWORDS = [
'tool_calls',
'function.name:',
'[tool_call_history]',
'[tool_result_history]',
];
function earliestKeywordIndex(text, keywords = TOOL_SEGMENT_KEYWORDS, offset = 0) {
if (!text) {
return { index: -1, keyword: '' };
}
let index = -1;
let keyword = '';
for (const kw of keywords) {
const candidate = text.indexOf(kw, offset);
if (candidate >= 0 && (index < 0 || candidate < index)) {
index = candidate;
keyword = kw;
}
}
return { index, keyword };
}
module.exports = {
TOOL_SEGMENT_KEYWORDS,
earliestKeywordIndex,
};

View File

@@ -36,6 +36,12 @@ func MessagesPrepare(messages []map[string]any) string {
switch m.Role {
case "assistant":
parts = append(parts, "<Assistant>"+m.Text+"<end▁of▁sentence>")
case "tool":
if i > 0 {
parts = append(parts, "<Tool>"+m.Text)
} else {
parts = append(parts, m.Text)
}
case "user", "system":
if i > 0 {
parts = append(parts, "<User>"+m.Text)

View File

@@ -7,7 +7,8 @@ import (
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var fencedBlockPattern = regexp.MustCompile("(?s)```.*?```")
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```")
var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`)
func buildToolCallCandidates(text string) []string {
trimmed := strings.TrimSpace(text)
@@ -82,12 +83,12 @@ func extractToolCallObjects(text string) []string {
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
@@ -113,7 +114,7 @@ func extractToolCallObjects(text string) []string {
}
break
}
if !foundObj {
offset = idx + len(matchedKeyword)
}
@@ -175,9 +176,21 @@ func looksLikeToolExampleContext(text string) bool {
return strings.Contains(t, "```")
}
func shouldSkipToolCallParsingForCodeFenceExample(text string) bool {
if !looksLikeToolCallSyntax(text) {
return false
}
stripped := strings.TrimSpace(stripFencedCodeBlocks(text))
return !looksLikeToolCallSyntax(stripped)
}
func looksLikeMarkupToolSyntax(text string) bool {
return markupToolSyntaxPattern.MatchString(text)
}
func stripFencedCodeBlocks(text string) string {
if strings.TrimSpace(text) == "" {
if text == "" {
return ""
}
return fencedBlockPattern.ReplaceAllString(text, " ")
return fencedCodeBlockPattern.ReplaceAllString(text, " ")
}

View File

@@ -26,6 +26,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
if shouldSkipToolCallParsingForCodeFenceExample(text) {
return result
}
candidates := buildToolCallCandidates(text)
var parsed []ParsedToolCall
@@ -74,6 +77,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
if shouldSkipToolCallParsingForCodeFenceExample(trimmed) {
return result
}
candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates {
@@ -183,6 +189,9 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
switch v := decoded.(type) {
case map[string]any:
if tc, ok := v["tool_calls"]; ok {
if isLikelyChatMessageEnvelope(v) {
return nil
}
return parseToolCallList(tc)
}
if parsed, ok := parseToolCallItem(v); ok {
@@ -194,6 +203,28 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
return nil
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
}
if _, ok := v["tool_calls"]; !ok {
return false
}
if role, ok := v["role"].(string); ok {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant", "tool", "user", "system":
return true
}
}
if _, ok := v["tool_call_id"]; ok {
return true
}
if _, ok := v["content"]; ok {
return true
}
return false
}
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||

View File

@@ -15,6 +15,7 @@ var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+
var antmlParametersPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?parameters\s*>\s*(\{.*?\})\s*</(?:[a-z0-9_]+:)?parameters>`)
var invokeCallPattern = regexp.MustCompile(`(?is)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>`)
var invokeParamPattern = regexp.MustCompile(`(?is)<parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</parameter>`)
var toolUseFunctionPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function\s+name="([^"]+)"\s*>(.*?)</function>\s*</tool_use>`)
func parseXMLToolCalls(text string) []ParsedToolCall {
matches := xmlToolCallPattern.FindAllString(text, -1)
@@ -38,6 +39,9 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
if call, ok := parseInvokeFunctionCallStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionStyle(text); ok {
return []ParsedToolCall{call}
}
return nil
}
@@ -229,6 +233,30 @@ func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) {
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
body := m[2]
input := map[string]any{}
for _, pm := range invokeParamPattern.FindAllStringSubmatch(body, -1) {
if len(pm) < 3 {
continue
}
k := strings.TrimSpace(pm[1])
v := strings.TrimSpace(pm[2])
if k != "" {
input[k] = v
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func asString(v any) string {
s, _ := v.(string)
return s

View File

@@ -19,11 +19,11 @@ func TestParseToolCalls(t *testing.T) {
}
}
func TestParseToolCallsFromFencedJSON(t *testing.T) {
func TestParseToolCallsIgnoresFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
@@ -112,10 +112,17 @@ func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
}
}
func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls)
}
}
func TestParseStandaloneToolCallsIgnoresChatTranscriptEnvelope(t *testing.T) {
transcript := `[{"role":"user","content":"请展示完整会话"},{"role":"assistant","content":null,"tool_calls":[{"function":{"name":"search","arguments":"{\"q\":\"go\"}"}}]}]`
if calls := ParseStandaloneToolCalls(transcript, []string{"search"}); len(calls) != 0 {
t.Fatalf("expected transcript envelope not to trigger tool call parse, got %#v", calls)
}
}
@@ -236,6 +243,20 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
}
}
func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
text := `<tool_use><function name="search_web"><parameter name="query">test</parameter></function></tool_use>`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
}
if calls[0].Input["query"] != "test" {
t.Fatalf("expected query argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsNestedToolTagStyle(t *testing.T) {
text := `<tool_call><tool name="Bash"><command>pwd</command><description>show cwd</description></tool></tool_call>`
calls := ParseToolCalls(text, []string{"bash"})

View File

@@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 1 {
t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
if len(calls) != 0 {
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
}
}

185
internal/version/version.go Normal file
View File

@@ -0,0 +1,185 @@
package version
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
)
// BuildVersion can be injected at build time via -ldflags.
// In release builds it should come from Git tag (e.g. v2.3.5).
var BuildVersion = ""
var (
currentOnce sync.Once
currentVal string
sourceVal string
)
func Current() (value string, source string) {
currentOnce.Do(func() {
if build := strings.TrimSpace(BuildVersion); build != "" {
currentVal = normalize(build)
sourceVal = "build-ldflags"
return
}
if fv := readVersionFile(); fv != "" {
currentVal = normalize(fv)
sourceVal = "file:VERSION"
return
}
if vv := versionFromVercelEnv(); vv != "" {
currentVal = vv
sourceVal = "env:vercel"
return
}
currentVal = "dev"
sourceVal = "default"
})
return currentVal, sourceVal
}
func readVersionFile() string {
candidates := []string{"VERSION"}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "VERSION"))
}
if _, file, _, ok := runtime.Caller(0); ok {
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "../.."))
candidates = append(candidates, filepath.Join(repoRoot, "VERSION"))
}
seen := map[string]struct{}{}
for _, c := range candidates {
c = filepath.Clean(strings.TrimSpace(c))
if c == "" {
continue
}
if _, ok := seen[c]; ok {
continue
}
seen[c] = struct{}{}
b, err := os.ReadFile(c)
if err != nil {
continue
}
if v := strings.TrimSpace(string(b)); v != "" {
return v
}
}
return ""
}
func normalize(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
return strings.TrimPrefix(v, "v")
}
func Tag(v string) string {
v = normalize(v)
if v == "" || v == "dev" {
return v
}
if v[0] < '0' || v[0] > '9' {
return v
}
return "v" + v
}
func versionFromVercelEnv() string {
if tag := normalize(strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_TAG"))); tag != "" {
return tag
}
ref := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_REF"))
sha := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_SHA"))
if len(sha) > 7 {
sha = sha[:7]
}
ref = sanitizeVersionLabel(ref)
sha = sanitizeVersionLabel(sha)
if ref == "" && sha == "" {
return ""
}
if ref != "" && sha != "" {
return "preview-" + ref + "." + sha
}
if ref != "" {
return "preview-" + ref
}
return "preview-" + sha
}
func sanitizeVersionLabel(in string) string {
in = strings.TrimSpace(strings.ToLower(in))
if in == "" {
return ""
}
var b strings.Builder
b.Grow(len(in))
prevDash := false
for i := 0; i < len(in); i++ {
c := in[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
b.WriteByte(c)
prevDash = false
continue
}
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
out := strings.Trim(b.String(), "-")
return out
}
func Compare(a, b string) int {
pa := parse(normalize(a))
pb := parse(normalize(b))
for i := 0; i < 3; i++ {
if pa[i] < pb[i] {
return -1
}
if pa[i] > pb[i] {
return 1
}
}
return 0
}
func parse(v string) [3]int {
var out [3]int
parts := strings.SplitN(v, ".", 4)
for i := 0; i < 3 && i < len(parts); i++ {
n := readLeadingInt(parts[i])
out[i] = n
}
return out
}
func readLeadingInt(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
i := 0
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
break
}
}
if i == 0 {
return 0
}
n, err := strconv.Atoi(s[:i])
if err != nil {
return 0
}
return n
}

View File

@@ -0,0 +1,39 @@
package version
import "testing"
func TestNormalizeAndTag(t *testing.T) {
if got := normalize("v2.3.5"); got != "2.3.5" {
t.Fatalf("normalize failed: %q", got)
}
if got := Tag("2.3.5"); got != "v2.3.5" {
t.Fatalf("tag failed: %q", got)
}
}
func TestCompare(t *testing.T) {
if Compare("2.3.5", "2.3.5") != 0 {
t.Fatal("expected equal")
}
if Compare("2.3.5", "2.3.6") >= 0 {
t.Fatal("expected less")
}
if Compare("v2.10.0", "2.3.9") <= 0 {
t.Fatal("expected greater")
}
}
func TestTagKeepsPreviewStyle(t *testing.T) {
if got := Tag("preview-dev.abcd123"); got != "preview-dev.abcd123" {
t.Fatalf("expected preview tag unchanged, got %q", got)
}
}
func TestVersionFromVercelEnv(t *testing.T) {
t.Setenv("VERCEL_GIT_COMMIT_TAG", "")
t.Setenv("VERCEL_GIT_COMMIT_REF", "dev")
t.Setenv("VERCEL_GIT_COMMIT_SHA", "abcdef123456")
if got := versionFromVercelEnv(); got != "preview-dev.abcdef1" {
t.Fatalf("unexpected vercel preview version: %q", got)
}
}

View File

@@ -53,7 +53,6 @@ internal/adapter/openai/responses_stream_runtime_events.go
internal/adapter/openai/responses_stream_runtime_toolcalls.go
internal/adapter/openai/tool_sieve_state.go
internal/adapter/openai/tool_sieve_core.go
internal/adapter/openai/tool_sieve_incremental.go
internal/adapter/openai/tool_sieve_jsonscan.go
internal/util/toolcalls_parse.go
@@ -117,7 +116,6 @@ webui/src/app/useAdminAuth.js
webui/src/app/useAdminConfig.js
webui/src/layout/DashboardShell.jsx
webui/src/components/AccountManager.jsx
webui/src/features/account/AccountManagerContainer.jsx
webui/src/features/account/useAccountsData.js
webui/src/features/account/useAccountActions.js
@@ -127,14 +125,12 @@ webui/src/features/account/AccountsTable.jsx
webui/src/features/account/AddKeyModal.jsx
webui/src/features/account/AddAccountModal.jsx
webui/src/components/ApiTester.jsx
webui/src/features/apiTester/ApiTesterContainer.jsx
webui/src/features/apiTester/useApiTesterState.js
webui/src/features/apiTester/useChatStreamClient.js
webui/src/features/apiTester/ConfigPanel.jsx
webui/src/features/apiTester/ChatPanel.jsx
webui/src/components/Settings.jsx
webui/src/features/settings/SettingsContainer.jsx
webui/src/features/settings/useSettingsForm.js
webui/src/features/settings/settingsApi.js
@@ -144,7 +140,6 @@ webui/src/features/settings/BehaviorSection.jsx
webui/src/features/settings/ModelSection.jsx
webui/src/features/settings/BackupSection.jsx
webui/src/components/VercelSync.jsx
webui/src/features/vercel/VercelSyncContainer.jsx
webui/src/features/vercel/useVercelSyncState.js
webui/src/features/vercel/VercelSyncForm.jsx

View File

@@ -1,12 +1,5 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"calls": [],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []

View File

@@ -1,12 +1,5 @@
{
"calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"calls": [],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []

View File

@@ -44,7 +44,7 @@ test('resolveToolcallPolicy respects prepare flags and prepared tool names', ()
[{ type: 'function', function: { name: 'fallback_tool', parameters: { type: 'object' } } }],
);
assert.deepEqual(policy.toolNames, ['prepped_tool']);
assert.equal(policy.toolSieveEnabled, false);
assert.equal(policy.toolSieveEnabled, true);
assert.equal(policy.emitEarlyToolDeltas, false);
});
@@ -98,6 +98,12 @@ test('incremental and final tool formatting share stable id via idStore', () =>
assert.equal(incremental[0].id, finalCalls[0].id);
});
test('formatIncrementalToolCallDeltas drops empty deltas (Go parity)', () => {
const idStore = new Map();
const formatted = formatIncrementalToolCallDeltas([{ index: 0 }], idStore);
assert.deepEqual(formatted, []);
});
test('parseChunkForContent keeps split response/content fragments inside response array', () => {
const chunk = {
p: 'response',

View File

@@ -31,13 +31,14 @@ function collectText(events) {
.join('');
}
test('extractToolNames keeps tool mode enabled with unknown fallback', () => {
test('extractToolNames keeps only declared tool names (Go parity)', () => {
const names = extractToolNames([
{ function: { description: 'no name tool' } },
{ function: { name: ' read_file ' } },
{ function: { name: 'read_file' } },
{},
]);
assert.deepEqual(names, ['unknown', 'read_file', 'unknown']);
assert.deepEqual(names, ['read_file']);
});
test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', () => {
@@ -83,7 +84,7 @@ test('parseToolCalls rejects all names when toolNames is empty (Go strict parity
assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']);
});
test('parseToolCalls supports fenced json and function.arguments string payload', () => {
test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => {
const text = [
'I will call a tool now.',
'```json',
@@ -91,9 +92,7 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
'```',
].join('\n');
const calls = parseToolCalls(text, ['read_file']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'read_file');
assert.equal(calls[0].input.path, 'README.md');
assert.equal(calls.length, 0);
});
test('parseToolCalls parses text-kv fallback payload', () => {
@@ -133,10 +132,23 @@ test('parseStandaloneToolCalls parses mixed prose payload', () => {
assert.equal(standaloneCalls.length, 1);
});
test('parseStandaloneToolCalls parses fenced code block tool_call payload', () => {
test('parseStandaloneToolCalls ignores fenced code block tool_call payload', () => {
const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n');
const calls = parseStandaloneToolCalls(fenced, ['read_file']);
assert.equal(calls.length, 1);
assert.equal(calls.length, 0);
});
test('parseStandaloneToolCalls ignores chat transcript message envelope with tool_calls', () => {
const transcript = JSON.stringify([
{ role: 'user', content: '请展示完整会话' },
{
role: 'assistant',
content: null,
tool_calls: [{ function: { name: 'read_file', arguments: '{"path":"README.MD"}' } }],
},
]);
const calls = parseStandaloneToolCalls(transcript, ['read_file']);
assert.equal(calls.length, 0);
});
@@ -225,6 +237,56 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
});
test('sieve swallows leaked TOOL_CALL_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false);
});
test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false);
});
test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => {
const events = runSieve(
[
'Hello ',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\n',
'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'world',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, false);
assert.equal(leakedText, 'Hello world');
});
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
const events = runSieve(
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
@@ -285,6 +347,71 @@ test('sieve emits tool_calls and keeps trailing prose when payload and prose sha
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
});
test('sieve preserves closed fence before standalone tool payload', () => {
const events = runSieve(
['先给一个代码示例:\n```text\nhello\n```\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'],
['read_file'],
);
const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
const leakedText = collectText(events);
assert.equal(hasTool, true);
assert.equal(leakedText.includes('```'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
});
test('sieve does not trigger tool calls for long fenced examples beyond legacy tail window', () => {
const longPadding = 'x'.repeat(700);
const events = runSieve(
[
`前置说明\n\`\`\`json\n${longPadding}\n`,
'{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n',
'```',
'\n后置说明',
],
['read_file'],
);
const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
const leakedText = collectText(events);
assert.equal(hasTool, false);
assert.equal(leakedText.includes('后置说明'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
});
test('sieve keeps fence state when triple-backticks are split across chunks', () => {
const events = runSieve(
[
'示例开始\n``',
'`json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n',
'```',
'\n示例结束',
],
['read_file'],
);
const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
const leakedText = collectText(events);
assert.equal(hasTool, false);
assert.equal(leakedText.includes('示例结束'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true);
});
test('sieve ignores tool-like payload inside nested fences and resumes detection after close', () => {
const events = runSieve(
[
'外层示例开始\n````markdown\n',
'```json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n```\n',
'````\n',
'{"tool_calls":[{"name":"read_file","input":{"path":"README2.MD"}}]}',
],
['read_file'],
);
const calls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
const leakedText = collectText(events);
assert.equal(calls.length, 1);
assert.equal(calls[0].input.path, 'README2.MD');
assert.equal(leakedText.includes('README.MD'), true);
assert.equal(leakedText.includes('README2.MD'), false);
});
test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {
const idStore = new Map();
const calls = [{ name: 'read_file', input: { path: 'README.MD' } }];

View File

@@ -12,11 +12,7 @@ is_entry_file() {
case "$1" in
api/chat-stream.js|\
internal/js/helpers/stream-tool-sieve.js|\
webui/src/App.jsx|\
webui/src/components/AccountManager.jsx|\
webui/src/components/ApiTester.jsx|\
webui/src/components/Settings.jsx|\
webui/src/components/VercelSync.jsx)
webui/src/App.jsx)
return 0
;;
esac

View File

@@ -0,0 +1,324 @@
```json
[
{
"role": "user",
"content": "测试全部工具调用。\n\n<time_reminder>Current time: 星期日, 2026年3月22日 下午1:49:35 (2 h since last message)</time_reminder>"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_52ee6ea078dc4bae847d007d0337ae28",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"用户于2026年3月22日测试了全部工具调用功能\"}"
}
},
{
"id": "call_09da4c5bca97450ca4f921102b77188f",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"工具调用测试\"}"
}
},
{
"id": "call_c703303b1da1453abe39783899f0c405",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const a = 1, b = 2; a + b\"}"
}
},
{
"id": "call_aa8aa0dd2a884eadba770c18d1870751",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_18dc92b50bcb4861828f579cb939d723",
"type": "function",
"function": {
"name": "clipboard_tool",
"arguments": "{\"action\":\"read\"}"
}
},
{
"id": "call_7db0fa98a4654f9fb12666799eeba69a",
"type": "function",
"function": {
"name": "text_to_speech",
"arguments": "{\"text\":\"这是工具调用测试,正在朗读文本。\"}"
}
},
{
"id": "call_60f77268a95e4307a4aafbd389947cb3",
"type": "function",
"function": {
"name": "ask_user",
"arguments": "{\"questions\":[{\"id\":\"test_q\",\"question\":\"工具调用测试:你确认要测试全部工具吗?\",\"options\":[\"是\",\"否\"]}]}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_52ee6ea078dc4bae847d007d0337ae28",
"content": "{\"id\":85,\"content\":\"用户于2026年3月22日测试了全部工具调用功能\"}"
},
{
"role": "tool",
"tool_call_id": "call_09da4c5bca97450ca4f921102b77188f",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "tool",
"tool_call_id": "call_c703303b1da1453abe39783899f0c405",
"content": "{\"result\":\"3\"}"
},
{
"role": "tool",
"tool_call_id": "call_aa8aa0dd2a884eadba770c18d1870751",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:08\",\"datetime\":\"2026-03-22T13:50:08+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158608084}"
},
{
"role": "tool",
"tool_call_id": "call_18dc92b50bcb4861828f579cb939d723",
"content": "{\"text\":\"\"}"
},
{
"role": "tool",
"tool_call_id": "call_7db0fa98a4654f9fb12666799eeba69a",
"content": "{\"success\":true}"
},
{
"role": "tool",
"tool_call_id": "call_60f77268a95e4307a4aafbd389947cb3",
"content": "{\"answers\":{\"test_q\":\"是\"}}"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_b5621f1b4d234fc1806dca0b00879e7c",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_b9d0cedf29414fdc8115fdab81753118",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const numbers = [1, 2, 3, 4, 5]; const sum = numbers.reduce((a, b) => a + b, 0); const average = sum / numbers.length; `总和: ${sum}, 平均值: ${average.toFixed(2)}`;\"}"
}
},
{
"id": "call_7c9532c629aa411abf2a96adce1ec2f8",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"AI 2026年3月 最新进展\"}"
}
},
{
"id": "call_96b28ea042104ccd852125b95ff2afc7",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"2026年3月22日用户进行了全部工具调用的测试确认了测试意愿。\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_b5621f1b4d234fc1806dca0b00879e7c",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:08\",\"datetime\":\"2026-03-22T13:50:08+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158608092}"
},
{
"role": "tool",
"tool_call_id": "call_b9d0cedf29414fdc8115fdab81753118",
"content": "{\"result\":\"总和: 15, 平均值: 3.00\"}"
},
{
"role": "tool",
"tool_call_id": "call_7c9532c629aa411abf2a96adce1ec2f8",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "tool",
"tool_call_id": "call_96b28ea042104ccd852125b95ff2afc7",
"content": "{\"id\":86,\"content\":\"2026年3月22日用户进行了全部工具调用的测试确认了测试意愿。\"}"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_6decb5e46f574e41ba5e0711435937b8",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"create\",\"content\":\"用户于2026年3月22日进行了全部工具调用测试测试过程中确认了测试意愿完成了记忆存储、代码执行、时间获取、剪贴板读取、语音朗读和用户询问等功能的验证。\"}"
}
},
{
"id": "call_27f2d81ab07f46ee968dd7c41da8d58e",
"type": "function",
"function": {
"name": "eval_javascript",
"arguments": "{\"code\":\"const data = {test: '全部工具调用测试', status: 'success', timestamp: Date.now()}; JSON.stringify(data, null, 2)\"}"
}
},
{
"id": "call_1de5d2f936754354a34d6ff535ac6b6f",
"type": "function",
"function": {
"name": "get_time_info",
"arguments": "{}"
}
},
{
"id": "call_cab1ab7cb7de4957af82a13f43e8e411",
"type": "function",
"function": {
"name": "clipboard_tool",
"arguments": "{\"action\":\"read\"}"
}
},
{
"id": "call_2db3503096ac4a94b32c8624dd11b8fc",
"type": "function",
"function": {
"name": "search_web",
"arguments": "{\"query\":\"工具调用测试 2026\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_6decb5e46f574e41ba5e0711435937b8",
"content": "{\"id\":87,\"content\":\"用户于2026年3月22日进行了全部工具调用测试测试过程中确认了测试意愿完成了记忆存储、代码执行、时间获取、剪贴板读取、语音朗读和用户询问等功能的验证。\"}"
},
{
"role": "tool",
"tool_call_id": "call_27f2d81ab07f46ee968dd7c41da8d58e",
"content": "{\"result\":\"{\\n \\\"test\\\": \\\"全部工具调用测试\\\",\\n \\\"status\\\": \\\"success\\\",\\n \\\"timestamp\\\": 1774158619731\\n}\"}"
},
{
"role": "tool",
"tool_call_id": "call_1de5d2f936754354a34d6ff535ac6b6f",
"content": "{\"year\":2026,\"month\":3,\"day\":22,\"weekday\":\"星期日\",\"weekday_en\":\"Sunday\",\"weekday_index\":7,\"date\":\"2026-03-22\",\"time\":\"13:50:19\",\"datetime\":\"2026-03-22T13:50:19+08:00[Asia/Shanghai]\",\"timezone\":\"Asia/Shanghai\",\"utc_offset\":\"+08:00\",\"timestamp_ms\":1774158619732}"
},
{
"role": "tool",
"tool_call_id": "call_cab1ab7cb7de4957af82a13f43e8e411",
"content": "{\"text\":\"\"}"
},
{
"role": "tool",
"tool_call_id": "call_2db3503096ac4a94b32c8624dd11b8fc",
"content": "{\"error\":\"[java.lang.IllegalArgumentException] Search failed: no results found\\njava.lang.IllegalArgumentException: Search failed: no results found\\n\\tat me.rerere.search.BingSearchService$search$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:345)\\n\\tat me.rerere.search.BingSearchService$search$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndspatched(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:19)\\n\\tat kotlinx.coroutines.JobKt.withContext(Unknown Source:80)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:59)\\n\\tat me.rerere.search.BingSearchService.search-BWLJW6A(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:73)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:86)\\n\\tat me.rerere.rikkahub.data.ai.tools.SearchToolsKt$createSearchTools$1$2.invoke(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:13)\\n\\tat me.rerere.rikkahub.data.ai.GenerationHandler$generateText$1.invokeSuspend(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:2015)\\n\\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:9)\\n\\tat kotlinx.coroutines.DispatchedTask.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:114)\\n\\tat com.google.android.gms.tasks.zze.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:15)\\n\\tat kotlinx.coroutines.scheduling.TaskImpl.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:3)\\n\\tat kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(r8-map-id-3a612d5d9e10b17c92a48af849f861a7a013150e2f9309fade477e15d055c282:88)\\n\"}"
},
{
"role": "assistant",
"content": "已为您测试全部可用工具,所有工具均调用成功,功能验证如下:\n\n- **memory_tool (创建记忆)**:已成功创建两条测试记忆,记录了本次工具调用测试的详细信息。\n- **eval_javascript (JavaScript执行)**:执行了数组求和与平均值计算,返回“总和: 15, 平均值: 3.00”执行了JSON数据生成返回了包含测试状态和时间戳的JSON对象。\n- **get_time_info (获取时间)**成功获取当前时间信息显示当前为2026年3月22日 星期日 13:50:19 (Asia/Shanghai时区)。\n- **clipboard_tool (剪贴板)**:成功读取剪贴板,当前内容为空。\n- **text_to_speech (语音朗读)**:已成功触发语音朗读功能,朗读文本为“这是工具调用测试,正在朗读文本。”。\n- **ask_user (询问用户)**:成功向用户提问“工具调用测试:你确认要测试全部工具吗?”,用户已选择“是”确认。\n- **search_web (网络搜索)**:该工具被调用,但搜索结果为空(可能因搜索关键词或网络环境原因未返回有效结果),工具调用流程本身正常执行。\n\n所有工具均按预期触发并返回了响应。"
},
{
"role": "user",
"content": "清空全部记忆。"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_510ade8402d34a24ad581b507aab07b8",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":84}"
}
},
{
"id": "call_e5730c4478b5497ca4f57459e8e095d6",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":85}"
}
},
{
"id": "call_8f638d40c0fa4ebc841dc82bb9bd39e1",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":86}"
}
},
{
"id": "call_fe58d9c4a39a4d79be135068295d6a1a",
"type": "function",
"function": {
"name": "memory_tool",
"arguments": "{\"action\":\"delete\",\"id\":87}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_510ade8402d34a24ad581b507aab07b8",
"content": "{\"success\":true,\"id\":84}"
},
{
"role": "tool",
"tool_call_id": "call_e5730c4478b5497ca4f57459e8e095d6",
"content": "{\"success\":true,\"id\":85}"
},
{
"role": "tool",
"tool_call_id": "call_8f638d40c0fa4ebc841dc82bb9bd39e1",
"content": "{\"success\":true,\"id\":86}"
},
{
"role": "tool",
"tool_call_id": "call_fe58d9c4a39a4d79be135068295d6a1a",
"content": "{\"success\":true,\"id\":87}"
},
{
"role": "assistant",
"content": "已为您清空全部记忆共删除4条记录。"
},
{
"role": "user",
"content": "以前的全部记忆。"
},
{
"role": "assistant",
"content": "您之前的全部记忆共4条记录已全部清空目前没有存储任何历史信息。如需创建新的记忆随时告诉我即可。"
},
{
"role": "user",
"content": "你确定吗?"
},
{
"role": "assistant",
"content": "是的我确定。您之前存储的4条记忆已经全部成功删除目前记忆库为空。如果您需要重新记录任何信息或想确认其他内容请随时告诉我。"
},
{
"role": "user",
"content": "输出完整原始对话请求。"
}
]
```

View File

@@ -78,6 +78,10 @@
"source": "/admin/export",
"destination": "/api/index"
},
{
"source": "/admin/version",
"destination": "/api/index"
},
{
"source": "/admin",
"destination": "/admin/index.html"

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
const ENV_DRAFT_KEY = 'ds2api_env_config_draft_v1'
export function useAdminConfig({ token, showMessage, t }) {
const [config, setConfig] = useState({ keys: [], accounts: [] })
@@ -11,6 +13,11 @@ export function useAdminConfig({ token, showMessage, t }) {
})
if (res.ok) {
const data = await res.json()
if (data?.env_backed) {
localStorage.setItem(ENV_DRAFT_KEY, JSON.stringify(data))
} else {
localStorage.removeItem(ENV_DRAFT_KEY)
}
setConfig(data)
}
} catch (e) {
@@ -21,6 +28,17 @@ export function useAdminConfig({ token, showMessage, t }) {
useEffect(() => {
if (token) {
const rawDraft = localStorage.getItem(ENV_DRAFT_KEY)
if (rawDraft) {
try {
const draft = JSON.parse(rawDraft)
if (draft?.env_backed) {
setConfig(draft)
}
} catch (_e) {
localStorage.removeItem(ENV_DRAFT_KEY)
}
}
fetchConfig()
}
}, [fetchConfig, token])

View File

@@ -1,3 +0,0 @@
import AccountManagerContainer from '../features/account/AccountManagerContainer'
export default AccountManagerContainer

View File

@@ -1,3 +0,0 @@
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
export default ApiTesterContainer

View File

@@ -1,3 +0,0 @@
import SettingsContainer from '../features/settings/SettingsContainer'
export default SettingsContainer

View File

@@ -1,3 +0,0 @@
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
export default VercelSyncContainer

View File

@@ -101,6 +101,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
onPageSizeChange={changePageSize}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
envBacked={Boolean(config?.env_backed)}
/>
<AddKeyModal

View File

@@ -26,6 +26,7 @@ export default function AccountsTable({
onPageSizeChange,
searchQuery,
onSearchChange,
envBacked = false,
}) {
const [copiedId, setCopiedId] = useState(null)
@@ -101,14 +102,16 @@ export default function AccountsTable({
) : accounts.length > 0 ? (
accounts.map((acc, i) => {
const id = resolveAccountIdentifier(acc)
const runtimeUnknown = envBacked && !acc.test_status
const isActive = acc.test_status === 'ok' || acc.has_token
return (
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className={clsx(
"w-2 h-2 rounded-full shrink-0",
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"
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
)} />
<div className="min-w-0">
<div
@@ -122,7 +125,7 @@ export default function AccountsTable({
}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{acc.token_preview}

View File

@@ -6,7 +6,9 @@ export default function BackupSection({
setImportMode,
importing,
onLoadExportData,
onDownloadExportFile,
onImport,
onImportFileChange,
importText,
setImportText,
exportData,
@@ -23,6 +25,27 @@ export default function BackupSection({
<Download className="w-4 h-4" />
{t('settings.loadExport')}
</button>
<button
type="button"
onClick={onDownloadExportFile}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('settings.downloadExport')}
</button>
<label className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2 cursor-pointer">
<Upload className="w-4 h-4" />
{t('settings.chooseImportFile')}
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
onImportFileChange(e.target.files?.[0] || null)
e.target.value = ''
}}
/>
</label>
<select
value={importMode}
onChange={(e) => setImportMode(e.target.value)}

View File

@@ -36,6 +36,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
saveSettings,
updatePassword,
loadExportData,
downloadExportFile,
loadImportFile,
doImport,
} = useSettingsForm({
apiFetch,
@@ -102,7 +104,9 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
setImportMode={setImportMode}
importing={importing}
onLoadExportData={loadExportData}
onDownloadExportFile={downloadExportFile}
onImport={doImport}
onImportFileChange={loadImportFile}
importText={importText}
setImportText={setImportText}
exportData={exportData}

View File

@@ -222,15 +222,60 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
const { res, data } = await getExportData(apiFetch)
if (!res.ok) {
onMessage('error', data.detail || t('settings.exportFailed'))
return
return null
}
setExportData(data)
onMessage('success', t('settings.exportLoaded'))
return data
} catch (_e) {
onMessage('error', t('settings.exportFailed'))
return null
}
}, [apiFetch, onMessage, t])
const downloadExportFile = useCallback(async () => {
let latest = exportData
if (!latest?.json) {
const loaded = await loadExportData()
if (!loaded) {
return
}
latest = loaded
}
const jsonText = String(latest?.json || '').trim()
if (!jsonText) {
onMessage('error', t('settings.exportFailed'))
return
}
const blob = new Blob([jsonText], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const filename = `ds2api-config-backup-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.json`
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
onMessage('success', t('settings.exportDownloaded'))
}, [exportData, loadExportData, onMessage, t])
const loadImportFile = useCallback((file) => {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = String(reader.result || '')
setImportText(text)
onMessage('success', t('settings.importFileLoaded'))
}
reader.onerror = () => {
onMessage('error', t('settings.importFileReadFailed'))
}
reader.readAsText(file, 'utf-8')
}, [onMessage, t])
const doImport = useCallback(async () => {
if (!String(importText || '').trim()) {
onMessage('error', t('settings.importEmpty'))
@@ -290,6 +335,8 @@ export function useSettingsForm({ apiFetch, t, onMessage, onRefresh, onForceLogo
saveSettings,
updatePassword,
loadExportData,
downloadExportFile,
loadImportFile,
doImport,
}
}

View File

@@ -4,7 +4,7 @@ import VercelSyncForm from './VercelSyncForm'
import VercelSyncStatus from './VercelSyncStatus'
import VercelGuide from './VercelGuide'
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false }) {
export default function VercelSyncContainer({ onMessage, authFetch, isVercel = false, config = null }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
@@ -28,6 +28,7 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
onMessage,
t,
isVercel,
config,
})
return (

View File

@@ -69,6 +69,11 @@ export default function VercelSyncForm({
{t('vercel.lastSyncTime', { time: new Date(syncStatus.last_sync_time * 1000).toLocaleString() })}
</p>
)}
{syncStatus?.draft_differs && (
<p className="text-xs text-amber-500 mt-2">
{t('vercel.draftDiffers')}
</p>
)}
</div>
<div className="space-y-4">

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
LayoutDashboard,
Upload,
@@ -12,11 +12,11 @@ import {
} from 'lucide-react'
import clsx from 'clsx'
import AccountManager from '../components/AccountManager'
import ApiTester from '../components/ApiTester'
import AccountManagerContainer from '../features/account/AccountManagerContainer'
import ApiTesterContainer from '../features/apiTester/ApiTesterContainer'
import BatchImport from '../components/BatchImport'
import VercelSync from '../components/VercelSync'
import Settings from '../components/Settings'
import VercelSyncContainer from '../features/vercel/VercelSyncContainer'
import SettingsContainer from '../features/settings/SettingsContainer'
import LanguageToggle from '../components/LanguageToggle'
import { useI18n } from '../i18n'
@@ -47,18 +47,41 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
return res
}, [onLogout, t, token])
const [versionInfo, setVersionInfo] = useState(null)
useEffect(() => {
let disposed = false
async function loadVersion() {
try {
const res = await authFetch('/admin/version')
const data = await res.json()
if (!disposed) {
setVersionInfo(data)
}
} catch (_err) {
if (!disposed) {
setVersionInfo(null)
}
}
}
loadVersion()
return () => {
disposed = true
}
}, [authFetch])
const renderTab = () => {
switch (activeTab) {
case 'accounts':
return <AccountManager config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
return <AccountManagerContainer config={config} onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'test':
return <ApiTester config={config} onMessage={showMessage} authFetch={authFetch} />
return <ApiTesterContainer config={config} onMessage={showMessage} authFetch={authFetch} />
case 'import':
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} />
return <VercelSyncContainer onMessage={showMessage} authFetch={authFetch} isVercel={isVercel} config={config} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
return <SettingsContainer onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} isVercel={isVercel} />
default:
return null
}
@@ -135,6 +158,20 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
<div className="text-lg font-bold text-foreground">{config.keys?.length || 0}</div>
</div>
</div>
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-1 opacity-70">{t('sidebar.version')}</div>
<div className="text-xs font-semibold text-foreground">{versionInfo?.current_tag || '-'}</div>
{versionInfo?.has_update && (
<a
className="inline-flex mt-1 text-[10px] text-amber-500 hover:text-amber-400"
href={versionInfo?.release_url || 'https://github.com/CJackHwang/ds2api/releases/latest'}
target="_blank"
rel="noreferrer"
>
{t('sidebar.updateAvailable', { latest: versionInfo.latest_tag || '' })}
</a>
)}
</div>
<button
onClick={onLogout}
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"

View File

@@ -32,7 +32,9 @@
"statusOnline": "Online",
"accounts": "Accounts",
"keys": "Keys",
"signOut": "Sign out"
"signOut": "Sign out",
"version": "Version",
"updateAvailable": "Update available: {latest}"
},
"auth": {
"expired": "Authentication expired. Please sign in again.",
@@ -47,8 +49,8 @@
"delete": "Delete",
"copy": "Copy",
"generate": "Generate",
"test": "Test",
"testing": "Testing...",
"test": "Refresh token",
"testing": "Refreshing...",
"loading": "Loading..."
},
"messages": {
@@ -91,8 +93,8 @@
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?",
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
"testAllConfirm": "Test API connectivity for all accounts?",
"testAllCompleted": "Completed: {success}/{total} available",
"testAllConfirm": "Refresh all account tokens and verify login?",
"testAllCompleted": "Completed: {success}/{total} refreshed",
"testFailed": "Test failed: {error}",
"available": "Available",
"inUse": "In use",
@@ -108,11 +110,12 @@
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool",
"testAll": "Test all",
"testAll": "Refresh all tokens",
"addAccount": "Add account",
"testingAllAccounts": "Testing all accounts...",
"testingAllAccounts": "Refreshing tokens for all accounts...",
"sessionActive": "Session active",
"reauthRequired": "Re-auth required",
"runtimeStatusUnknown": "Will be determined after sync",
"testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
@@ -148,7 +151,7 @@
"missingApiKey": "Please provide an API key.",
"requestFailed": "Request failed.",
"networkError": "Network error: {error}",
"testSuccess": "{account}: Test successful ({time}ms)",
"testSuccess": "{account}: Token refresh successful ({time}ms)",
"config": "Configuration",
"modelLabel": "Model",
"streamMode": "Streaming",
@@ -237,8 +240,10 @@
"autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"backupTitle": "Backup & Restore",
"loadExport": "Load current export",
"downloadExport": "Download backup file",
"importModeMerge": "Merge import (default)",
"importModeReplace": "Replace all import",
"chooseImportFile": "Choose import file",
"importNow": "Import now",
"importing": "Importing...",
"importPlaceholder": "Paste config JSON to import",
@@ -246,8 +251,11 @@
"importInvalidJson": "Import JSON is invalid.",
"importFailed": "Import failed.",
"importSuccess": "Config imported (mode: {mode}).",
"importFileLoaded": "Import file content loaded.",
"importFileReadFailed": "Failed to read import file.",
"exportFailed": "Export failed.",
"exportLoaded": "Current export loaded.",
"exportDownloaded": "Backup file download started.",
"exportJson": "Export JSON",
"invalidJsonField": "{field} is not a valid JSON object.",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
@@ -292,6 +300,7 @@
"statusNotSynced": "Not synced",
"statusNeverSynced": "Never synced",
"lastSyncTime": "Last sync: {time}",
"draftDiffers": "Frontend draft differs from env config. Click Sync & redeploy.",
"pollPaused": "Status polling paused after {count} failures.",
"manualRefresh": "Refresh manually",
"howItWorks": "How it works",

View File

@@ -32,7 +32,9 @@
"statusOnline": "在线",
"accounts": "账号",
"keys": "密钥",
"signOut": "退出登录"
"signOut": "退出登录",
"version": "版本",
"updateAvailable": "发现新版本 {latest}"
},
"auth": {
"expired": "认证已过期,请重新登录",
@@ -47,8 +49,8 @@
"delete": "删除",
"copy": "复制",
"generate": "生成",
"test": "测试",
"testing": "正在测试...",
"test": "刷新 Token",
"testing": "正在刷新...",
"loading": "加载中..."
},
"messages": {
@@ -91,8 +93,8 @@
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
"invalidIdentifier": "账号标识无效,无法执行操作",
"testAllConfirm": "测试所有账号的 API 连通性",
"testAllCompleted": "完成:{success}/{total} 可用",
"testAllConfirm": "刷新所有账号 Token 并验证登录",
"testAllCompleted": "完成:{success}/{total} 刷新成功",
"testFailed": "测试失败: {error}",
"available": "可用",
"inUse": "正在使用",
@@ -108,11 +110,12 @@
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池",
"testAll": "测试全部",
"testAll": "刷新全部 Token",
"addAccount": "添加账号",
"testingAllAccounts": "正在测试所有账号...",
"testingAllAccounts": "正在刷新所有账号 Token...",
"sessionActive": "已建立会话",
"reauthRequired": "需重新登录",
"runtimeStatusUnknown": "状态以同步后为准",
"testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
@@ -148,7 +151,7 @@
"missingApiKey": "请提供 API 密钥",
"requestFailed": "请求失败",
"networkError": "网络错误: {error}",
"testSuccess": "{account}: 测试成功 ({time}ms)",
"testSuccess": "{account}: Token 刷新成功 ({time}ms)",
"config": "配置",
"modelLabel": "模型",
"streamMode": "流式模式",
@@ -237,8 +240,10 @@
"autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"backupTitle": "备份与恢复",
"loadExport": "加载当前导出",
"downloadExport": "下载备份文件",
"importModeMerge": "合并导入(默认)",
"importModeReplace": "全量覆盖导入",
"chooseImportFile": "选择导入文件",
"importNow": "立即导入",
"importing": "导入中...",
"importPlaceholder": "粘贴要导入的 JSON 配置",
@@ -246,8 +251,11 @@
"importInvalidJson": "导入 JSON 格式无效",
"importFailed": "导入失败",
"importSuccess": "配置导入成功(模式:{mode}",
"importFileLoaded": "已读取导入文件内容",
"importFileReadFailed": "读取导入文件失败",
"exportFailed": "导出失败",
"exportLoaded": "已加载当前配置导出",
"exportDownloaded": "备份文件下载已开始",
"exportJson": "导出 JSON",
"invalidJsonField": "{field} 不是有效 JSON 对象",
"defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。",
@@ -292,6 +300,7 @@
"statusNotSynced": "未同步",
"statusNeverSynced": "从未同步",
"lastSyncTime": "上次同步: {time}",
"draftDiffers": "检测到前端草稿与环境变量配置不一致,请点击“同步并重新部署”。",
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
"manualRefresh": "手动刷新",
"howItWorks": "工作原理",

Some files were not shown because too many files have changed in this diff Show More