mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-02 07:25:26 +08:00
Compare commits
107 Commits
v2.3.0_Bet
...
v2.3.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1b76c90f | ||
|
|
1e7e0b2ae3 | ||
|
|
fd158e5ae2 | ||
|
|
95c96f7744 | ||
|
|
e7f59fac80 | ||
|
|
1bf059396f | ||
|
|
696b403173 | ||
|
|
f4db2732b0 | ||
|
|
ee88a74dcf | ||
|
|
ca08bb66b9 | ||
|
|
708fcb5beb | ||
|
|
7a65d1eaa2 | ||
|
|
6de2457743 | ||
|
|
ce44e260bf | ||
|
|
09f6537ffc | ||
|
|
ab8f494fdb | ||
|
|
b56a211da9 | ||
|
|
fcce5308cb | ||
|
|
d27b19cc53 | ||
|
|
b8ff678f24 | ||
|
|
b24ef1282d | ||
|
|
65e0de3c82 | ||
|
|
0c2743a48c | ||
|
|
dc73e8a6da | ||
|
|
b8495eeeb3 | ||
|
|
b3eae22cef | ||
|
|
7af0098d1b | ||
|
|
17405be300 | ||
|
|
5bc03e5de6 | ||
|
|
5a5f93148d | ||
|
|
32dc5b6099 | ||
|
|
7936d4675f | ||
|
|
808eafa7c6 | ||
|
|
bcb8ed6df2 | ||
|
|
8ec5dcc0cc | ||
|
|
88a79f212d | ||
|
|
b1f8d6192f | ||
|
|
acfb3b225d | ||
|
|
99a6164000 | ||
|
|
e49d9d33e2 | ||
|
|
184a3d1e4e | ||
|
|
c4ec14f49a | ||
|
|
fb5fc0e885 | ||
|
|
20b603666d | ||
|
|
4d549b7102 | ||
|
|
33b0d1d144 | ||
|
|
41c0f7ce28 | ||
|
|
efb484ba4f | ||
|
|
145501d4a5 | ||
|
|
2d5103997b | ||
|
|
52e7e7aae8 | ||
|
|
5b5a4000d7 | ||
|
|
2bbf603148 | ||
|
|
d14b8a0664 | ||
|
|
f16e0b579e | ||
|
|
43cbc4aac0 | ||
|
|
cf569f4749 | ||
|
|
c9c59f2490 | ||
|
|
16216cc2ca | ||
|
|
de50fd3954 | ||
|
|
7648d5f192 | ||
|
|
d35e5eab25 | ||
|
|
90610a52ce | ||
|
|
f6296d506f | ||
|
|
dfea092583 | ||
|
|
af7dc134bb | ||
|
|
2657d37f76 | ||
|
|
7318d1f4a8 | ||
|
|
f2674487c7 | ||
|
|
71cdcb43e8 | ||
|
|
9c46c3a874 | ||
|
|
12d5f136d5 | ||
|
|
00c37d8d2f | ||
|
|
0f1985af4a | ||
|
|
fa8affe1b7 | ||
|
|
c59a0b7799 | ||
|
|
bd72b91f27 | ||
|
|
9240f85246 | ||
|
|
ea4bd1e483 | ||
|
|
9e0de62707 | ||
|
|
128de290db | ||
|
|
286d266723 | ||
|
|
8aad1005b2 | ||
|
|
11b2f24fc2 | ||
|
|
d1f08cbb89 | ||
|
|
60e9d707d4 | ||
|
|
9b93badb57 | ||
|
|
892213071a | ||
|
|
5484d6e59d | ||
|
|
0ce3fd22a7 | ||
|
|
25e40cc3a6 | ||
|
|
af68d21095 | ||
|
|
1fafd25e86 | ||
|
|
5f8f28a943 | ||
|
|
94cf1bfcc7 | ||
|
|
13562cf521 | ||
|
|
d27e700c4f | ||
|
|
d6bce5af93 | ||
|
|
75969e710d | ||
|
|
6c39c8e191 | ||
|
|
0e261ff0a0 | ||
|
|
fab326eca1 | ||
|
|
c033eceee7 | ||
|
|
1c749b6803 | ||
|
|
0bf5d5440c | ||
|
|
6daeb2553d | ||
|
|
958bd124cc |
98
.env.example
98
.env.example
@@ -1,93 +1,15 @@
|
||||
# DS2API environment template (Go runtime)
|
||||
# Copy this file to .env and adjust values.
|
||||
# Updated: 2026-02
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Runtime
|
||||
# ---------------------------------------------------------------
|
||||
# HTTP listen port (default: 5001)
|
||||
# DS2API runtime
|
||||
PORT=5001
|
||||
|
||||
# Log level: DEBUG | INFO | WARN | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Max concurrent inflight requests per account in managed-key mode.
|
||||
# Default: 2
|
||||
# Recommended client concurrency is calculated dynamically as:
|
||||
# account_count * DS2API_ACCOUNT_MAX_INFLIGHT
|
||||
# So by default it is account_count * 2.
|
||||
# Requests beyond inflight slots enter a waiting queue first.
|
||||
# Default queue size equals recommended concurrency, so 429 starts after:
|
||||
# account_count * DS2API_ACCOUNT_MAX_INFLIGHT * 2
|
||||
# Alias: DS2API_ACCOUNT_CONCURRENCY
|
||||
# DS2API_ACCOUNT_MAX_INFLIGHT=2
|
||||
# Admin authentication
|
||||
DS2API_ADMIN_KEY=change-me
|
||||
|
||||
# Optional waiting queue size override for managed-key mode.
|
||||
# Default: recommended_concurrency (same as account_count * inflight_limit)
|
||||
# Alias: DS2API_ACCOUNT_QUEUE_SIZE
|
||||
# DS2API_ACCOUNT_MAX_QUEUE=10
|
||||
# Config loading (choose one)
|
||||
# 1) file-based config
|
||||
DS2API_CONFIG_PATH=/app/config.json
|
||||
# 2) inline JSON or Base64 JSON
|
||||
# DS2API_CONFIG_JSON=
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Admin auth
|
||||
# ---------------------------------------------------------------
|
||||
# Admin key for /admin login and protected admin APIs.
|
||||
# Default is "admin" when unset, but setting it explicitly is recommended.
|
||||
DS2API_ADMIN_KEY=admin
|
||||
|
||||
# Optional JWT signing secret for admin token.
|
||||
# Defaults to DS2API_ADMIN_KEY when unset.
|
||||
# DS2API_JWT_SECRET=change-me
|
||||
|
||||
# Optional admin JWT validity in hours (default: 24)
|
||||
# DS2API_JWT_EXPIRE_HOURS=24
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Config source (choose one)
|
||||
# ---------------------------------------------------------------
|
||||
# Option A: config file path (local/dev recommended)
|
||||
# DS2API_CONFIG_PATH=config.json
|
||||
|
||||
# Option B: JSON string
|
||||
# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]}
|
||||
|
||||
# Option C: Base64 encoded JSON (recommended for Vercel env var)
|
||||
# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0=
|
||||
#
|
||||
# Generate from local config.json:
|
||||
# DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Paths (optional)
|
||||
# ---------------------------------------------------------------
|
||||
# WASM file used for PoW solving
|
||||
# DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm
|
||||
|
||||
# Built admin static assets directory
|
||||
# DS2API_STATIC_ADMIN_DIR=static/admin
|
||||
|
||||
# Auto-build WebUI on startup when static/admin is missing.
|
||||
# Default: enabled on local/Docker, disabled on Vercel.
|
||||
# DS2API_AUTO_BUILD_WEBUI=true
|
||||
|
||||
# Internal auth secret used by the Vercel hybrid streaming path
|
||||
# (Go prepare endpoint <-> Node stream function).
|
||||
# Optional: falls back to DS2API_ADMIN_KEY when unset.
|
||||
# DS2API_VERCEL_INTERNAL_SECRET=change-me
|
||||
|
||||
# Stream lease TTL seconds for Vercel hybrid streaming.
|
||||
# During this window, the managed account stays occupied until Node calls release.
|
||||
# Default: 900 (15 minutes)
|
||||
# DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS=900
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Vercel sync integration (optional)
|
||||
# ---------------------------------------------------------------
|
||||
# VERCEL_TOKEN=your-vercel-token
|
||||
# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx
|
||||
# VERCEL_TEAM_ID=team_xxxxxxxxxxxx
|
||||
|
||||
# Optional: Vercel deployment protection bypass secret.
|
||||
# If deployment protection is enabled, DS2API will use this value as
|
||||
# x-vercel-protection-bypass for internal Node->Go calls on Vercel.
|
||||
# You can also use VERCEL_AUTOMATION_BYPASS_SECRET directly.
|
||||
# DS2API_VERCEL_PROTECTION_BYPASS=your-bypass-secret
|
||||
# Optional: static admin assets path
|
||||
# DS2API_STATIC_ADMIN_DIR=/app/static/admin
|
||||
|
||||
6
.github/workflows/release-artifacts.yml
vendored
6
.github/workflows/release-artifacts.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/release-dockerhub.yml
vendored
2
.github/workflows/release-dockerhub.yml
vendored
@@ -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
|
||||
|
||||
258
.github/workflows/release.yml
vendored
258
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
2
API.md
@@ -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`
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ ds2api/
|
||||
├── api/
|
||||
│ ├── index.go # Vercel Serverless Go entry
|
||||
│ ├── chat-stream.js # Vercel Node.js stream relay
|
||||
│ └── helpers/ # Node.js helper modules
|
||||
│ └── (rewrite targets in vercel.json)
|
||||
├── internal/
|
||||
│ ├── account/ # Account pool and concurrency queue
|
||||
│ ├── adapter/
|
||||
@@ -112,6 +112,7 @@ ds2api/
|
||||
│ ├── compat/ # Compatibility helpers
|
||||
│ ├── config/ # Config loading and hot-reload
|
||||
│ ├── deepseek/ # DeepSeek client, PoW WASM
|
||||
│ ├── js/ # Node runtime stream/compat logic
|
||||
│ ├── devcapture/ # Dev packet capture
|
||||
│ ├── format/ # Output formatting
|
||||
│ ├── prompt/ # Prompt building
|
||||
@@ -123,7 +124,9 @@ ds2api/
|
||||
│ └── webui/ # WebUI static hosting
|
||||
├── webui/ # React WebUI source
|
||||
│ └── src/
|
||||
│ ├── components/ # Components
|
||||
│ ├── app/ # Routing, auth, config state
|
||||
│ ├── features/ # Feature modules
|
||||
│ ├── components/ # Shared components
|
||||
│ └── locales/ # Language packs
|
||||
├── scripts/ # Build and test scripts
|
||||
├── static/admin/ # WebUI build output (not committed)
|
||||
|
||||
@@ -99,7 +99,7 @@ ds2api/
|
||||
├── api/
|
||||
│ ├── index.go # Vercel Serverless Go 入口
|
||||
│ ├── chat-stream.js # Vercel Node.js 流式转发
|
||||
│ └── helpers/ # Node.js 辅助模块
|
||||
│ └── (rewrite targets in vercel.json)
|
||||
├── internal/
|
||||
│ ├── account/ # 账号池与并发队列
|
||||
│ ├── adapter/
|
||||
@@ -112,6 +112,7 @@ ds2api/
|
||||
│ ├── compat/ # 兼容性辅助
|
||||
│ ├── config/ # 配置加载与热更新
|
||||
│ ├── deepseek/ # DeepSeek 客户端、PoW WASM
|
||||
│ ├── js/ # Node 运行时流式/兼容逻辑
|
||||
│ ├── devcapture/ # 开发抓包
|
||||
│ ├── format/ # 输出格式化
|
||||
│ ├── prompt/ # Prompt 构建
|
||||
@@ -123,7 +124,9 @@ ds2api/
|
||||
│ └── webui/ # WebUI 静态托管
|
||||
├── webui/ # React WebUI 源码
|
||||
│ └── src/
|
||||
│ ├── components/ # 组件
|
||||
│ ├── app/ # 路由、鉴权、配置状态
|
||||
│ ├── features/ # 业务功能模块
|
||||
│ ├── components/ # 通用组件
|
||||
│ └── locales/ # 语言包
|
||||
├── scripts/ # 构建与测试脚本
|
||||
├── static/admin/ # WebUI 构建产物(不提交)
|
||||
|
||||
13
DEPLOY.en.md
13
DEPLOY.en.md
@@ -113,12 +113,8 @@ go build -o ds2api ./cmd/ds2api
|
||||
# Copy env template
|
||||
cp .env.example .env
|
||||
|
||||
# Generate single-line Base64 from config.json
|
||||
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
|
||||
|
||||
# Edit .env and set:
|
||||
# Edit .env and set at least:
|
||||
# DS2API_ADMIN_KEY=your-admin-key
|
||||
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
|
||||
|
||||
# Start
|
||||
docker-compose up -d
|
||||
@@ -185,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).
|
||||
|
||||
---
|
||||
@@ -366,7 +363,7 @@ Each archive includes:
|
||||
|
||||
- `ds2api` executable (`ds2api.exe` on Windows)
|
||||
- `static/admin/` (built WebUI assets)
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm`
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm` (optional; binary has embedded fallback)
|
||||
- `config.example.json`, `.env.example`
|
||||
- `README.MD`, `README.en.md`, `LICENSE`
|
||||
|
||||
@@ -455,7 +452,9 @@ server {
|
||||
```bash
|
||||
# Copy compiled binary and related files to target directory
|
||||
sudo mkdir -p /opt/ds2api
|
||||
sudo cp ds2api config.json sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
|
||||
sudo cp ds2api config.json /opt/ds2api/
|
||||
# Optional: if you want to use an external WASM file (override embedded one)
|
||||
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
|
||||
sudo cp -r static/admin /opt/ds2api/static/admin
|
||||
```
|
||||
|
||||
|
||||
13
DEPLOY.md
13
DEPLOY.md
@@ -113,12 +113,8 @@ go build -o ds2api ./cmd/ds2api
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 从 config.json 生成单行 Base64
|
||||
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
|
||||
|
||||
# 编辑 .env(请改成你的强密码),设置:
|
||||
# 编辑 .env(请改成你的强密码),至少设置:
|
||||
# DS2API_ADMIN_KEY=your-admin-key
|
||||
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
|
||||
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
@@ -185,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` 登录(建议首次登录后自行更换为强密码)。
|
||||
|
||||
---
|
||||
@@ -366,7 +363,7 @@ No Output Directory named "public" found after the Build completed.
|
||||
|
||||
- `ds2api` 可执行文件(Windows 为 `ds2api.exe`)
|
||||
- `static/admin/`(WebUI 构建产物)
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm`
|
||||
- `sha3_wasm_bg.7b9ca65ddd.wasm`(可选;程序内置 embed fallback)
|
||||
- `config.example.json`、`.env.example`
|
||||
- `README.MD`、`README.en.md`、`LICENSE`
|
||||
|
||||
@@ -455,7 +452,9 @@ server {
|
||||
```bash
|
||||
# 将编译好的二进制文件和相关文件复制到目标目录
|
||||
sudo mkdir -p /opt/ds2api
|
||||
sudo cp ds2api config.json sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
|
||||
sudo cp ds2api config.json /opt/ds2api/
|
||||
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本)
|
||||
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
|
||||
sudo cp -r static/admin /opt/ds2api/static/admin
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
64
README.MD
64
README.MD
@@ -106,6 +106,14 @@ flowchart LR
|
||||
可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。
|
||||
另外,`/anthropic/v1/models` 现已包含 Claude 1.x/2.x/3.x/4.x 历史模型 ID 与常见别名,便于旧客户端直接兼容。
|
||||
|
||||
|
||||
#### Claude Code 接入避坑(实测)
|
||||
|
||||
- `ANTHROPIC_BASE_URL` 推荐直接指向 DS2API 根地址(例如 `http://127.0.0.1:5001`),Claude Code 会请求 `/v1/messages?beta=true`。
|
||||
- `ANTHROPIC_API_KEY` 需要与 `config.json` 中 `keys` 一致;建议同时保留常规 key 与 `sk-ant-*` 形态 key,兼容不同客户端校验习惯。
|
||||
- 若系统设置了代理,建议对 DS2API 地址配置 `NO_PROXY=127.0.0.1,localhost,<你的主机IP>`,避免本地回环请求被代理拦截。
|
||||
- 如遇“工具调用输出成文本、未执行”问题,请升级到包含 Claude 工具调用多格式解析(JSON/XML/ANTML/invoke)的版本。
|
||||
|
||||
### Gemini 接口
|
||||
|
||||
Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型,支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling(`functionDeclarations` → `functionCall` 输出)。
|
||||
@@ -152,17 +160,13 @@ go run ./cmd/ds2api
|
||||
# 1. 准备环境变量文件
|
||||
cp .env.example .env
|
||||
|
||||
# 2. 从 config.json 生成 DS2API_CONFIG_JSON(单行 Base64)
|
||||
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
|
||||
|
||||
# 3. 编辑 .env,设置:
|
||||
# 2. 编辑 .env(至少设置 DS2API_ADMIN_KEY)
|
||||
# DS2API_ADMIN_KEY=请替换为强密码
|
||||
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
|
||||
|
||||
# 4. 启动
|
||||
# 3. 启动
|
||||
docker-compose up -d
|
||||
|
||||
# 5. 查看日志
|
||||
# 4. 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
@@ -174,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
|
||||
@@ -238,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": {
|
||||
@@ -265,7 +269,7 @@ cp opencode.json.example opencode.json
|
||||
"embeddings": {
|
||||
"provider": "deterministic"
|
||||
},
|
||||
"claude_model_mapping": {
|
||||
"claude_mapping": {
|
||||
"fast": "deepseek-chat",
|
||||
"slow": "deepseek-reasoner"
|
||||
},
|
||||
@@ -276,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 热更新)
|
||||
|
||||
### 环境变量
|
||||
|
||||
@@ -389,7 +397,7 @@ ds2api/
|
||||
├── api/
|
||||
│ ├── index.go # Vercel Serverless Go 入口
|
||||
│ ├── chat-stream.js # Vercel Node.js 流式转发
|
||||
│ └── helpers/ # Node.js 辅助模块
|
||||
│ └── (rewrite targets in vercel.json)
|
||||
├── internal/
|
||||
│ ├── account/ # 账号池与并发队列
|
||||
│ ├── adapter/
|
||||
@@ -402,6 +410,7 @@ ds2api/
|
||||
│ ├── compat/ # 兼容性辅助
|
||||
│ ├── config/ # 配置加载与热更新
|
||||
│ ├── deepseek/ # DeepSeek API 客户端、PoW WASM
|
||||
│ ├── js/ # Node 运行时流式处理与兼容逻辑
|
||||
│ ├── devcapture/ # 开发抓包模块
|
||||
│ ├── format/ # 输出格式化
|
||||
│ ├── prompt/ # Prompt 构建
|
||||
@@ -412,7 +421,9 @@ ds2api/
|
||||
│ └── webui/ # WebUI 静态文件托管与自动构建
|
||||
├── webui/ # React WebUI 源码(Vite + Tailwind)
|
||||
│ └── src/
|
||||
│ ├── components/ # AccountManager / ApiTester / BatchImport / VercelSync / Login / LandingPage
|
||||
│ ├── app/ # 路由、鉴权、配置状态管理
|
||||
│ ├── features/ # 业务功能模块(account/settings/vercel/apiTester)
|
||||
│ ├── components/ # 登录/落地页等通用组件
|
||||
│ └── locales/ # 中英文语言包(zh.json / en.json)
|
||||
├── scripts/
|
||||
│ └── build-webui.sh # WebUI 手动构建脚本
|
||||
@@ -468,6 +479,23 @@ go run ./cmd/ds2api-tests \
|
||||
npm ci --prefix webui && npm run build --prefix webui
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
详细测试指南请参阅 [TESTING.md](TESTING.md)。
|
||||
|
||||
### 快速测试命令
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
go test ./...
|
||||
|
||||
# 运行 tool calls 相关测试(调试工具调用问题)
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
|
||||
# 运行端到端测试
|
||||
./tests/scripts/run-live.sh
|
||||
```
|
||||
|
||||
## Release 自动构建(GitHub Actions)
|
||||
|
||||
工作流文件:`.github/workflows/release-artifacts.yml`
|
||||
@@ -475,7 +503,7 @@ npm ci --prefix webui && npm run build --prefix webui
|
||||
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
|
||||
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`)+ `sha256sums.txt`
|
||||
- **容器镜像发布**:仅推送到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
||||
- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件、配置示例、README、LICENSE
|
||||
- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback)、配置示例、README、LICENSE
|
||||
|
||||
## 免责声明
|
||||
|
||||
|
||||
47
README.en.md
47
README.en.md
@@ -106,6 +106,14 @@ flowchart LR
|
||||
Override mapping via `claude_mapping` or `claude_model_mapping` in config.
|
||||
In addition, `/anthropic/v1/models` now includes historical Claude 1.x/2.x/3.x/4.x IDs and common aliases for legacy client compatibility.
|
||||
|
||||
|
||||
#### Claude Code integration pitfalls (validated)
|
||||
|
||||
- Set `ANTHROPIC_BASE_URL` to the DS2API root URL (for example `http://127.0.0.1:5001`). Claude Code sends requests to `/v1/messages?beta=true`.
|
||||
- `ANTHROPIC_API_KEY` must match an entry in `keys` from `config.json`. Keeping both a regular key and an `sk-ant-*` style key improves client compatibility.
|
||||
- If your environment has proxy variables, set `NO_PROXY=127.0.0.1,localhost,<your_host_ip>` for DS2API to avoid proxy interception of local traffic.
|
||||
- If tool calls are rendered as plain text and not executed, upgrade to a build that includes multi-format Claude tool-call parsing (JSON/XML/ANTML/invoke).
|
||||
|
||||
### Gemini Endpoint
|
||||
|
||||
The Gemini adapter maps model names to DeepSeek native models via `model_aliases` or built-in heuristics, supporting both `generateContent` and `streamGenerateContent` call patterns with full Tool Calling support (`functionDeclarations` → `functionCall` output).
|
||||
@@ -152,17 +160,13 @@ Default URL: `http://localhost:5001`
|
||||
# 1. Prepare env file
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Generate DS2API_CONFIG_JSON from config.json (single-line Base64)
|
||||
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
|
||||
|
||||
# 3. Edit .env and set:
|
||||
# 2. Edit .env (at least set DS2API_ADMIN_KEY)
|
||||
# DS2API_ADMIN_KEY=replace-with-a-strong-secret
|
||||
# DS2API_CONFIG_JSON=${DS2API_CONFIG_JSON}
|
||||
|
||||
# 4. Start
|
||||
# 3. Start
|
||||
docker-compose up -d
|
||||
|
||||
# 5. View logs
|
||||
# 4. View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
@@ -174,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
|
||||
@@ -238,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": {
|
||||
@@ -265,7 +269,7 @@ cp opencode.json.example opencode.json
|
||||
"embeddings": {
|
||||
"provider": "deterministic"
|
||||
},
|
||||
"claude_model_mapping": {
|
||||
"claude_mapping": {
|
||||
"fast": "deepseek-chat",
|
||||
"slow": "deepseek-reasoner"
|
||||
},
|
||||
@@ -276,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
|
||||
|
||||
@@ -390,7 +398,7 @@ ds2api/
|
||||
├── api/
|
||||
│ ├── index.go # Vercel Serverless Go entry
|
||||
│ ├── chat-stream.js # Vercel Node.js stream relay
|
||||
│ └── helpers/ # Node.js helper modules
|
||||
│ └── (rewrite targets in vercel.json)
|
||||
├── internal/
|
||||
│ ├── account/ # Account pool and concurrency queue
|
||||
│ ├── adapter/
|
||||
@@ -403,6 +411,7 @@ ds2api/
|
||||
│ ├── compat/ # Compatibility helpers
|
||||
│ ├── config/ # Config loading and hot-reload
|
||||
│ ├── deepseek/ # DeepSeek API client, PoW WASM
|
||||
│ ├── js/ # Node runtime stream/compat logic
|
||||
│ ├── devcapture/ # Dev packet capture module
|
||||
│ ├── format/ # Output formatting
|
||||
│ ├── prompt/ # Prompt construction
|
||||
@@ -413,7 +422,9 @@ ds2api/
|
||||
│ └── webui/ # WebUI static file serving and auto-build
|
||||
├── webui/ # React WebUI source (Vite + Tailwind)
|
||||
│ └── src/
|
||||
│ ├── components/ # AccountManager / ApiTester / BatchImport / VercelSync / Login / LandingPage
|
||||
│ ├── app/ # Routing, auth, config state
|
||||
│ ├── features/ # Feature modules (account/settings/vercel/apiTester)
|
||||
│ ├── components/ # Shared UI pieces (login/landing, etc.)
|
||||
│ └── locales/ # Language packs (zh.json / en.json)
|
||||
├── scripts/
|
||||
│ └── build-webui.sh # Manual WebUI build script
|
||||
@@ -476,7 +487,7 @@ Workflow: `.github/workflows/release-artifacts.yml`
|
||||
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
|
||||
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
|
||||
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
||||
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file, config template, README, LICENSE
|
||||
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), config template, README, LICENSE
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
46
TESTING.md
46
TESTING.md
@@ -51,7 +51,7 @@ DS2API 提供两个层级的测试:
|
||||
1. **Preflight 检查**:
|
||||
- `go test ./... -count=1`(单元测试)
|
||||
- `./tests/scripts/check-node-split-syntax.sh`(Node 拆分模块语法门禁)
|
||||
- `node --test`(如仓库存在 Node 单测文件时执行;当前默认以 Go 测试 + Node 语法门禁为主)
|
||||
- `node --test tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js tests/node/js_compat_test.js`
|
||||
- `npm run build --prefix webui`(WebUI 构建检查)
|
||||
|
||||
2. **隔离启动**:复制 `config.json` 到临时目录,启动独立服务进程
|
||||
@@ -173,6 +173,50 @@ rg "<trace_id>" artifacts/testsuite/<run_id>/server.log
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### 运行特定模块的单元测试
|
||||
|
||||
```bash
|
||||
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
|
||||
# 运行单个测试用例
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
|
||||
|
||||
# 运行 format 相关测试
|
||||
go test -v ./internal/format/...
|
||||
|
||||
# 运行 adapter 相关测试
|
||||
go test -v ./internal/adapter/openai/...
|
||||
```
|
||||
|
||||
### 调试 Tool Call 问题 | Debugging Tool Call Issues
|
||||
|
||||
当遇到 DeepSeek 工具调用解析问题时,可以使用以下方法:
|
||||
|
||||
```bash
|
||||
# 1. 运行 tool calls 相关的所有测试
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
|
||||
# 2. 查看测试输出中的详细调试信息
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
|
||||
|
||||
# 3. 检查具体测试用例的修复效果
|
||||
# 测试用例位于 internal/util/toolcalls_test.go,包含:
|
||||
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
|
||||
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
|
||||
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
|
||||
```
|
||||
|
||||
### 运行 Node.js 测试
|
||||
|
||||
```bash
|
||||
# 运行 Node 测试
|
||||
node --test tests/node/stream-tool-sieve.test.js
|
||||
|
||||
# 或使用脚本
|
||||
./tests/scripts/run-unit-node.sh
|
||||
```
|
||||
|
||||
### 跑端到端测试(跳过 preflight)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
41
docs/toolcall-semantics.md
Normal file
41
docs/toolcall-semantics.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Tool call parsing semantics (Go canonical spec)
|
||||
|
||||
This document defines the cross-runtime contract for `ParseToolCallsDetailed` / `parseToolCallsDetailed`.
|
||||
|
||||
## Output contract
|
||||
|
||||
- `calls`: accepted tool calls with normalized tool names.
|
||||
- `sawToolCallSyntax`: true when tool-call-like syntax is detected (`tool_calls`, `<tool_call>`, `<function_call>`, `<invoke>`) or a valid call is parsed.
|
||||
- `rejectedByPolicy`: true when parser extracted call syntax but all calls are rejected by allow-list policy.
|
||||
- `rejectedToolNames`: de-duplicated rejected tool names in first-seen order.
|
||||
|
||||
## Parse pipeline
|
||||
|
||||
1. Strip fenced code blocks for non-standalone parsing.
|
||||
2. Build candidates from:
|
||||
- full text,
|
||||
- fenced JSON snippets,
|
||||
- extracted JSON objects around `tool_calls`,
|
||||
- first `{` to last `}` object slice.
|
||||
3. Parse each candidate in order:
|
||||
- JSON payload parser (`tool_calls`, list, single call object),
|
||||
- XML/Markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields),
|
||||
- Text KV fallback parser (`function.name: <name>` ... `function.arguments: {json}`).
|
||||
4. Stop at first candidate that yields at least one call.
|
||||
|
||||
## Name normalization policy
|
||||
|
||||
When matching parsed names against configured tools:
|
||||
|
||||
1. exact match,
|
||||
2. case-insensitive match,
|
||||
3. namespace tail match (`a.b.c` => `c`),
|
||||
4. loose alnum match (remove non `[a-z0-9]`, compare).
|
||||
|
||||
## Standalone mode
|
||||
|
||||
Standalone mode (`ParseStandaloneToolCallsDetailed`) parses the whole input directly (no candidate slicing), while still applying:
|
||||
|
||||
- example-context guard,
|
||||
- JSON then markup fallback,
|
||||
- the same allow-list normalization policy.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,3 +315,83 @@ func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{name: "xml_tool_call", payload: `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`},
|
||||
{name: "xml_json_tool_call", payload: `<tool_call>{"tool":"Bash","params":{"command":"pwd"}}</tool_call>`},
|
||||
{name: "nested_tool_tag_style", payload: `<tool_call><tool name="Bash"><command>pwd</command></tool></tool_call>`},
|
||||
{name: "function_tag_style", payload: `<function_call>Bash</function_call><function parameter name="command">pwd</function parameter>`},
|
||||
{name: "antml_argument_style", payload: `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument></antml:function_call></antml:function_calls>`},
|
||||
{name: "antml_function_attr_parameters", payload: `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`},
|
||||
{name: "invoke_parameter_style", payload: `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter></invoke></function_calls>`},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"`+strings.ReplaceAll(tc.payload, `"`, `\"`)+`"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"})
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
contentBlock, _ := f.Payload["content_block"].(map[string]any)
|
||||
if contentBlock["type"] == "tool_use" {
|
||||
foundToolUse = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundToolUse {
|
||||
t.Fatalf("expected tool_use block for format %s, body=%s", tc.name, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(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\\\"}}]}\"}",
|
||||
"data: {\"p\":\"response/content\",\"v\":\"\\n```\\nDo not execute it.\"}",
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
contentBlock, _ := f.Payload["content_block"].(map[string]any)
|
||||
if contentBlock["type"] == "tool_use" {
|
||||
foundToolUse = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundToolUse {
|
||||
t.Fatalf("expected tool_use for fenced example, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
foundToolStop := false
|
||||
for _, f := range findClaudeFrames(frames, "message_delta") {
|
||||
delta, _ := f.Payload["delta"].(map[string]any)
|
||||
if delta["stop_reason"] == "tool_use" {
|
||||
foundToolStop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundToolStop {
|
||||
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +125,11 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
|
||||
if !containsStr(prompt, "Search the web") {
|
||||
t.Fatalf("expected description in prompt")
|
||||
}
|
||||
if !containsStr(prompt, "tool_calls") {
|
||||
t.Fatalf("expected tool_calls instruction in prompt")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func buildClaudeToolPrompt(tools []any) string {
|
||||
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
|
||||
}
|
||||
parts = append(parts,
|
||||
"When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}",
|
||||
"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.",
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"ds2api/internal/sse"
|
||||
streamengine "ds2api/internal/stream"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type claudeStreamRuntime struct {
|
||||
@@ -116,6 +117,18 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
|
||||
s.text.WriteString(p.Text)
|
||||
if s.bufferToolContent {
|
||||
if hasUnclosedCodeFence(s.text.String()) {
|
||||
continue
|
||||
}
|
||||
detected := util.ParseToolCalls(s.text.String(), s.toolNames)
|
||||
if len(detected) > 0 {
|
||||
s.finalize("tool_use")
|
||||
return streamengine.ParsedDecision{
|
||||
ContentSeen: true,
|
||||
Stop: true,
|
||||
StopReason: streamengine.StopReason("tool_use_detected"),
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.closeThinkingBlock()
|
||||
@@ -144,3 +157,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
|
||||
return streamengine.ParsedDecision{ContentSeen: contentSeen}
|
||||
}
|
||||
|
||||
func hasUnclosedCodeFence(text string) bool {
|
||||
return strings.Count(text, "```")%2 == 1
|
||||
}
|
||||
|
||||
@@ -98,11 +98,11 @@ func (s *chatStreamRuntime) sendDone() {
|
||||
func (s *chatStreamRuntime) finalize(finishReason string) {
|
||||
finalThinking := s.thinking.String()
|
||||
finalText := s.text.String()
|
||||
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
|
||||
if len(detected) > 0 && !s.toolCallsDoneEmitted {
|
||||
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
|
||||
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
|
||||
finishReason = "tool_calls"
|
||||
delta := map[string]any{
|
||||
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected, s.streamToolCallIDs),
|
||||
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs),
|
||||
}
|
||||
if !s.firstChunkSent {
|
||||
delta["role"] = "assistant"
|
||||
@@ -158,7 +158,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(detected) > 0 || s.toolCallsEmitted {
|
||||
if len(detected.Calls) > 0 || s.toolCallsEmitted {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
s.sendChunk(openaifmt.BuildChatStreamChunk(
|
||||
|
||||
@@ -19,6 +19,7 @@ type DeepSeekCaller interface {
|
||||
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
|
||||
DeleteAllSessionsForToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type ConfigReader interface {
|
||||
@@ -28,6 +29,7 @@ type ConfigReader interface {
|
||||
ToolcallEarlyEmitConfidence() string
|
||||
ResponsesStoreTTLSeconds() int
|
||||
EmbeddingsProvider() string
|
||||
AutoDeleteSessions() bool
|
||||
}
|
||||
|
||||
var _ AuthResolver = (*auth.Resolver)(nil)
|
||||
|
||||
@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
|
||||
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
|
||||
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
|
||||
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
|
||||
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
|
||||
|
||||
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
|
||||
cfg := mockOpenAIConfig{
|
||||
|
||||
@@ -35,7 +35,25 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
writeOpenAIError(w, status, detail)
|
||||
return
|
||||
}
|
||||
defer h.Auth.Release(a)
|
||||
defer func() {
|
||||
// 自动删除会话(同步)
|
||||
// 必须在 Release 之前同步删除,否则:
|
||||
// 1. 异步删除时账号已被 Release
|
||||
// 2. 新请求可能获取到同一账号并开始使用
|
||||
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
|
||||
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
|
||||
deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := h.DS.DeleteAllSessionsForToken(deleteCtx, a.DeepSeekToken)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
|
||||
} else {
|
||||
config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
|
||||
}
|
||||
}
|
||||
h.Auth.Release(a)
|
||||
}()
|
||||
|
||||
r = r.WithContext(auth.WithAuth(r.Context(), a))
|
||||
|
||||
var req map[string]any
|
||||
|
||||
@@ -53,7 +53,7 @@ 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 this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\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. The response must start with { 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."
|
||||
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."
|
||||
if policy.Mode == util.ToolChoiceRequired {
|
||||
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) {
|
||||
func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"下面是示例:"}`,
|
||||
@@ -229,20 +229,21 @@ func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) {
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if choice["finish_reason"] != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"])
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
}
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
if _, ok := msg["tool_calls"]; ok {
|
||||
t.Fatalf("did not expect tool_calls field for embedded example: %#v", msg["tool_calls"])
|
||||
toolCalls, _ := msg["tool_calls"].([]any)
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"])
|
||||
}
|
||||
content, _ := msg["content"].(string)
|
||||
if !strings.Contains(content, "下面是示例:") || !strings.Contains(content, "请勿执行。") || !strings.Contains(content, `"tool_calls"`) {
|
||||
t.Fatalf("expected embedded example to remain plain text, got %#v", content)
|
||||
if strings.Contains(content, `"tool_calls"`) {
|
||||
t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
|
||||
func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
|
||||
@@ -258,16 +259,17 @@ func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
|
||||
out := decodeJSONBody(t, rec.Body.String())
|
||||
choices, _ := out["choices"].([]any)
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
if choice["finish_reason"] != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"])
|
||||
if choice["finish_reason"] != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
|
||||
}
|
||||
msg, _ := choice["message"].(map[string]any)
|
||||
if _, ok := msg["tool_calls"]; ok {
|
||||
t.Fatalf("did not expect tool_calls field for fenced example: %#v", msg["tool_calls"])
|
||||
toolCalls, _ := msg["tool_calls"].([]any)
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
|
||||
}
|
||||
content, _ := msg["content"].(string)
|
||||
if !strings.Contains(content, "```json") || !strings.Contains(content, `"tool_calls"`) {
|
||||
t.Fatalf("expected fenced tool example to pass through as text, got %q", content)
|
||||
if strings.Contains(content, `"tool_calls"`) {
|
||||
t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +617,7 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
|
||||
func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeSSEHTTPResponse(
|
||||
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"),
|
||||
@@ -631,8 +633,8 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
|
||||
if !done {
|
||||
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
|
||||
}
|
||||
if streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("did not expect tool_calls delta for fenced snippet, body=%s", rec.Body.String())
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta for fenced snippet, body=%s", rec.Body.String())
|
||||
}
|
||||
content := strings.Builder{}
|
||||
for _, frame := range frames {
|
||||
@@ -646,11 +648,11 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
got := content.String()
|
||||
if !strings.Contains(got, "```json") || !strings.Contains(strings.ToLower(got), "tool_calls") {
|
||||
t.Fatalf("expected fenced tool snippet in content, got=%q", got)
|
||||
if strings.Contains(strings.ToLower(got), "tool_calls") {
|
||||
t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
|
||||
}
|
||||
if streamFinishReason(frames) != "stop" {
|
||||
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/prompt"
|
||||
)
|
||||
|
||||
func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]any {
|
||||
@@ -77,7 +78,7 @@ func formatAssistantToolCallsForPrompt(msg map[string]any, traceID string) strin
|
||||
args = normalizeOpenAIArgumentsForPrompt(fn["arguments"])
|
||||
}
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
continue
|
||||
}
|
||||
if args == "" {
|
||||
args = normalizeOpenAIArgumentsForPrompt(call["arguments"])
|
||||
@@ -132,32 +133,7 @@ func formatToolResultForPrompt(msg map[string]any) string {
|
||||
}
|
||||
|
||||
func normalizeOpenAIContentForPrompt(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []any:
|
||||
parts := make([]string, 0, len(x))
|
||||
for _, item := range x {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := strings.ToLower(strings.TrimSpace(asString(m["type"])))
|
||||
if t != "text" && t != "output_text" && t != "input_text" {
|
||||
continue
|
||||
}
|
||||
if text := asString(m["text"]); text != "" {
|
||||
parts = append(parts, text)
|
||||
continue
|
||||
}
|
||||
if text := asString(m["content"]); text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return marshalToPromptString(v)
|
||||
}
|
||||
return prompt.NormalizeContent(v)
|
||||
}
|
||||
|
||||
func normalizeOpenAIArgumentsForPrompt(v any) string {
|
||||
|
||||
@@ -194,6 +194,59 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"tool_calls": []any{
|
||||
map[string]any{
|
||||
"id": "call_missing_name",
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"arguments": `{"path":"README.MD"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 0 {
|
||||
t.Fatalf("expected nameless assistant tool_calls to be dropped, got %#v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLiteral(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": nil,
|
||||
"tool_calls": []any{
|
||||
map[string]any{
|
||||
"id": "call_screenshot",
|
||||
"function": map[string]any{
|
||||
"name": "send_file_to_user",
|
||||
"arguments": `{"file_path":"/tmp/a.png"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected one normalized message, got %d", len(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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIMessagesForPrompt_DeveloperRoleMapsToSystem(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{"role": "developer", "content": "必须先走工具调用"},
|
||||
@@ -207,3 +260,23 @@ func TestNormalizeOpenAIMessagesForPrompt_DeveloperRoleMapsToSystem(t *testing.T
|
||||
t.Fatalf("expected developer role converted to system, got %#v", normalized[0]["role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIMessagesForPrompt_AssistantArrayContentFallbackWhenTextEmpty(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "", "content": "工具说明文本"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
|
||||
if len(normalized) != 1 {
|
||||
t.Fatalf("expected one normalized message, got %d", len(normalized))
|
||||
}
|
||||
content, _ := normalized[0]["content"].(string)
|
||||
if content != "工具说明文本" {
|
||||
t.Fatalf("expected content fallback text preserved, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,16 @@ func (s *responsesStreamRuntime) closeMessageItem() {
|
||||
outputIndex := s.ensureMessageOutputIndex()
|
||||
text := s.visibleText.String()
|
||||
if s.messagePartAdded {
|
||||
s.sendEvent(
|
||||
"response.output_text.done",
|
||||
openaifmt.BuildResponsesTextDonePayload(
|
||||
s.responseID,
|
||||
itemID,
|
||||
outputIndex,
|
||||
0,
|
||||
text,
|
||||
),
|
||||
)
|
||||
s.sendEvent(
|
||||
"response.content_part.done",
|
||||
openaifmt.BuildResponsesContentPartDonePayload(
|
||||
|
||||
@@ -226,6 +226,40 @@ func TestHandleResponsesStreamMultiToolCallKeepsNameAndCallIDAligned(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamEmitsOutputTextDoneBeforeContentPartDone(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
sseLine := func(v string) string {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"p": "response/content",
|
||||
"v": v,
|
||||
})
|
||||
return "data: " + string(b) + "\n"
|
||||
}
|
||||
|
||||
streamBody := sseLine("hello") + "data: [DONE]\n"
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(streamBody)),
|
||||
}
|
||||
|
||||
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "event: response.output_text.done") {
|
||||
t.Fatalf("expected response.output_text.done payload, body=%s", body)
|
||||
}
|
||||
textDoneIdx := strings.Index(body, "event: response.output_text.done")
|
||||
partDoneIdx := strings.Index(body, "event: response.content_part.done")
|
||||
if textDoneIdx < 0 || partDoneIdx < 0 {
|
||||
t.Fatalf("expected output_text.done + content_part.done, body=%s", body)
|
||||
}
|
||||
if textDoneIdx > partDoneIdx {
|
||||
t.Fatalf("expected output_text.done before content_part.done, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
@@ -263,7 +297,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *testing.T) {
|
||||
func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *testing.T) {
|
||||
h := &Handler{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -299,6 +333,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
|
||||
responseObj, _ := completedPayload["response"].(map[string]any)
|
||||
output, _ := responseObj["output"].([]any)
|
||||
hasMessage := false
|
||||
hasFunctionCall := false
|
||||
for _, item := range output {
|
||||
m, _ := item.(map[string]any)
|
||||
if m == nil {
|
||||
@@ -308,12 +343,15 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
|
||||
hasMessage = true
|
||||
}
|
||||
if asString(m["type"]) == "function_call" {
|
||||
t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output)
|
||||
hasFunctionCall = true
|
||||
}
|
||||
}
|
||||
if !hasMessage {
|
||||
t.Fatalf("expected message output for mixed prose tool example, output=%#v", output)
|
||||
}
|
||||
if !hasFunctionCall {
|
||||
t.Fatalf("expected function_call output for mixed prose tool example, output=%#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
|
||||
|
||||
@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
|
||||
return m.resp, nil
|
||||
}
|
||||
|
||||
func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
|
||||
body := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
@@ -167,15 +171,15 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
outputText, _ := out["output_text"].(string)
|
||||
if outputText == "" {
|
||||
t.Fatalf("expected output_text preserved for mixed prose payload")
|
||||
if outputText != "" {
|
||||
t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
|
||||
}
|
||||
output, _ := out["output"].([]any)
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("expected one output item, got %#v", output)
|
||||
}
|
||||
first, _ := output[0].(map[string]any)
|
||||
if first["type"] != "message" {
|
||||
t.Fatalf("expected message output item, got %#v", output)
|
||||
if first["type"] != "function_call" {
|
||||
t.Fatalf("expected function_call output item, got %#v", output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,22 +167,22 @@ func findToolSegmentStart(s string) int {
|
||||
return -1
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
offset := 0
|
||||
for {
|
||||
keyRel := strings.Index(lower[offset:], "tool_calls")
|
||||
if keyRel < 0 {
|
||||
return -1
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
|
||||
bestKeyIdx := -1
|
||||
for _, kw := range keywords {
|
||||
idx := strings.Index(lower, kw)
|
||||
if idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx) {
|
||||
bestKeyIdx = idx
|
||||
}
|
||||
keyIdx := offset + keyRel
|
||||
start := strings.LastIndex(s[:keyIdx], "{")
|
||||
if start < 0 {
|
||||
start = keyIdx
|
||||
}
|
||||
if !insideCodeFence(s[:start]) {
|
||||
return start
|
||||
}
|
||||
offset = keyIdx + len("tool_calls")
|
||||
}
|
||||
if bestKeyIdx < 0 {
|
||||
return -1
|
||||
}
|
||||
start := strings.LastIndex(s[:bestKeyIdx], "{")
|
||||
if start < 0 {
|
||||
start = bestKeyIdx
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
|
||||
@@ -191,13 +191,22 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
return "", nil, "", false
|
||||
}
|
||||
lower := strings.ToLower(captured)
|
||||
keyIdx := strings.Index(lower, "tool_calls")
|
||||
|
||||
keyIdx := -1
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_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 {
|
||||
return "", nil, "", false
|
||||
start = keyIdx
|
||||
}
|
||||
obj, end, ok := extractJSONObjectFrom(captured, start)
|
||||
if !ok {
|
||||
@@ -205,9 +214,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
}
|
||||
prefixPart := captured[:start]
|
||||
suffixPart := captured[end:]
|
||||
if insideCodeFence(state.recentTextTail + prefixPart) {
|
||||
return captured, nil, "", true
|
||||
}
|
||||
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
||||
if len(parsed.Calls) == 0 {
|
||||
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
|
||||
@@ -215,6 +221,9 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
// consume it to avoid leaking raw tool_calls JSON to user content.
|
||||
return prefixPart, nil, suffixPart, true
|
||||
}
|
||||
// If it has obvious keywords but failed to parse even after loose repair,
|
||||
// we still might want to intercept it if it looks like an attempt at tool call.
|
||||
// For now, keep the original logic but rely on loose JSON repair.
|
||||
return captured, nil, "", true
|
||||
}
|
||||
return prefixPart, parsed.Calls, suffixPart, true
|
||||
|
||||
@@ -19,9 +19,6 @@ func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
if insideCodeFence(state.recentTextTail + captured[:start]) {
|
||||
return nil
|
||||
}
|
||||
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
|
||||
if hasMultiple {
|
||||
state.disableDeltas = true
|
||||
|
||||
@@ -27,6 +27,7 @@ type ConfigStore interface {
|
||||
RuntimeAccountMaxInflight() int
|
||||
RuntimeAccountMaxQueue(defaultSize int) int
|
||||
RuntimeGlobalMaxInflight(defaultSize int) int
|
||||
AutoDeleteSessions() bool
|
||||
}
|
||||
|
||||
type PoolController interface {
|
||||
@@ -40,6 +41,8 @@ type DeepSeekCaller interface {
|
||||
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
|
||||
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
|
||||
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
|
||||
DeleteAllSessionsForToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
var _ ConfigStore = (*config.Store)(nil)
|
||||
|
||||
@@ -31,6 +31,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
pr.Get("/queue/status", h.queueStatus)
|
||||
pr.Post("/accounts/test", h.testSingleAccount)
|
||||
pr.Post("/accounts/test-all", h.testAllAccounts)
|
||||
pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
|
||||
pr.Post("/import", h.batchImport)
|
||||
pr.Post("/test", h.testAPI)
|
||||
pr.Post("/vercel/sync", h.syncVercel)
|
||||
@@ -38,5 +39,6 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
pr.Get("/export", h.exportConfig)
|
||||
pr.Get("/dev/captures", h.getDevCaptures)
|
||||
pr.Delete("/dev/captures", h.clearDevCaptures)
|
||||
pr.Get("/version", h.getVersion)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
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,16 +124,26 @@ 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()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// 获取会话数量
|
||||
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
|
||||
if sessionErr == nil && sessionStats != nil {
|
||||
result["session_count"] = sessionStats.FirstPageCount
|
||||
}
|
||||
|
||||
if strings.TrimSpace(message) == "" {
|
||||
result["success"] = true
|
||||
result["message"] = "API 测试成功(仅会话创建)"
|
||||
result["message"] = "Token 刷新成功(登录与会话创建成功)"
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
@@ -210,3 +227,45 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
|
||||
}
|
||||
|
||||
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
identifier, _ := req["identifier"].(string)
|
||||
if strings.TrimSpace(identifier) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
|
||||
return
|
||||
}
|
||||
acc, ok := findAccountByIdentifier(h.Store, identifier)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 每次先登录刷新一次 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)
|
||||
if err != nil {
|
||||
// token 可能过期,尝试重新登录并重试一次
|
||||
newToken, loginErr := h.DS.Login(r.Context(), acc)
|
||||
if loginErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
token = newToken
|
||||
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
|
||||
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/deepseek"
|
||||
)
|
||||
|
||||
type testingDSMock struct {
|
||||
loginCalls int
|
||||
createSessionCalls int
|
||||
getPowCalls int
|
||||
callCompletionCalls int
|
||||
loginCalls int
|
||||
createSessionCalls int
|
||||
getPowCalls int
|
||||
callCompletionCalls int
|
||||
deleteAllSessionsCalls int
|
||||
deleteAllSessionsError error
|
||||
deleteAllSessionsErrorOnce bool
|
||||
}
|
||||
|
||||
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
|
||||
@@ -38,6 +45,22 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
|
||||
return nil, errors.New("should not call CallCompletion in this test")
|
||||
}
|
||||
|
||||
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
|
||||
m.deleteAllSessionsCalls++
|
||||
if m.deleteAllSessionsError != nil {
|
||||
err := m.deleteAllSessionsError
|
||||
if m.deleteAllSessionsErrorOnce {
|
||||
m.deleteAllSessionsError = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
|
||||
return &deepseek.SessionStats{Success: true}, nil
|
||||
}
|
||||
|
||||
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
|
||||
store := config.LoadStore()
|
||||
@@ -54,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 {
|
||||
@@ -74,3 +97,38 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
|
||||
t.Fatalf("expected test status ok, got %q", updated.TestStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
|
||||
store := config.LoadStore()
|
||||
ds := &testingDSMock{deleteAllSessionsError: errors.New("token expired"), deleteAllSessionsErrorOnce: true}
|
||||
h := &Handler{Store: store, DS: ds}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/delete-all", bytes.NewBufferString(`{"identifier":"batch@example.com"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.deleteAllSessions(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if ok, _ := resp["success"].(bool); !ok {
|
||||
t.Fatalf("expected success response, got %#v", resp)
|
||||
}
|
||||
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)
|
||||
}
|
||||
updated, ok := store.FindAccount("batch@example.com")
|
||||
if !ok {
|
||||
t.Fatal("expected account")
|
||||
}
|
||||
if updated.Token != "new-token" {
|
||||
t.Fatalf("expected refreshed token persisted, got %q", updated.Token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,15 +7,30 @@ import (
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) {
|
||||
func boolFrom(v any) bool {
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.ToLower(strings.TrimSpace(x)) == "true"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
|
||||
var (
|
||||
adminCfg *config.AdminConfig
|
||||
runtimeCfg *config.RuntimeConfig
|
||||
toolcallCfg *config.ToolcallConfig
|
||||
respCfg *config.ResponsesConfig
|
||||
embCfg *config.EmbeddingsConfig
|
||||
claudeMap map[string]string
|
||||
aliasMap map[string]string
|
||||
adminCfg *config.AdminConfig
|
||||
runtimeCfg *config.RuntimeConfig
|
||||
toolcallCfg *config.ToolcallConfig
|
||||
respCfg *config.ResponsesConfig
|
||||
embCfg *config.EmbeddingsConfig
|
||||
autoDeleteCfg *config.AutoDeleteConfig
|
||||
claudeMap map[string]string
|
||||
aliasMap map[string]string
|
||||
)
|
||||
|
||||
if raw, ok := req["admin"].(map[string]any); ok {
|
||||
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["jwt_expire_hours"]; exists {
|
||||
n := intFrom(v)
|
||||
if n < 1 || n > 720 {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
|
||||
}
|
||||
cfg.JWTExpireHours = n
|
||||
}
|
||||
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["account_max_inflight"]; exists {
|
||||
n := intFrom(v)
|
||||
if n < 1 || n > 256 {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
|
||||
}
|
||||
cfg.AccountMaxInflight = n
|
||||
}
|
||||
if v, exists := raw["account_max_queue"]; exists {
|
||||
n := intFrom(v)
|
||||
if n < 1 || n > 200000 {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
|
||||
}
|
||||
cfg.AccountMaxQueue = n
|
||||
}
|
||||
if v, exists := raw["global_max_inflight"]; exists {
|
||||
n := intFrom(v)
|
||||
if n < 1 || n > 200000 {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
|
||||
}
|
||||
cfg.GlobalMaxInflight = n
|
||||
}
|
||||
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
|
||||
}
|
||||
runtimeCfg = cfg
|
||||
}
|
||||
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
case "feature_match", "off":
|
||||
cfg.Mode = mode
|
||||
default:
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
|
||||
}
|
||||
}
|
||||
if v, exists := raw["early_emit_confidence"]; exists {
|
||||
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
case "high", "low", "off":
|
||||
cfg.EarlyEmitConfidence = level
|
||||
default:
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
|
||||
}
|
||||
}
|
||||
toolcallCfg = cfg
|
||||
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
if v, exists := raw["store_ttl_seconds"]; exists {
|
||||
n := intFrom(v)
|
||||
if n < 30 || n > 86400 {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
|
||||
}
|
||||
cfg.StoreTTLSeconds = n
|
||||
}
|
||||
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
cfg := &config.EmbeddingsConfig{}
|
||||
if v, exists := raw["provider"]; exists {
|
||||
p := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if p == "" {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
|
||||
}
|
||||
cfg.Provider = p
|
||||
}
|
||||
embCfg = cfg
|
||||
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
|
||||
}
|
||||
}
|
||||
|
||||
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil
|
||||
if raw, ok := req["auto_delete"].(map[string]any); ok {
|
||||
cfg := &config.AutoDeleteConfig{}
|
||||
if v, exists := raw["sessions"]; exists {
|
||||
cfg.Sessions = boolFrom(v)
|
||||
}
|
||||
autoDeleteCfg = cfg
|
||||
}
|
||||
|
||||
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
"toolcall": snap.Toolcall,
|
||||
"responses": snap.Responses,
|
||||
"embeddings": snap.Embeddings,
|
||||
"auto_delete": snap.AutoDelete,
|
||||
"claude_mapping": settingsClaudeMapping(snap),
|
||||
"model_aliases": snap.ModelAliases,
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
|
||||
@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
|
||||
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
|
||||
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
|
||||
}
|
||||
if autoDeleteCfg != nil {
|
||||
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
|
||||
}
|
||||
if claudeMap != nil {
|
||||
c.ClaudeMapping = claudeMap
|
||||
c.ClaudeModelMap = nil
|
||||
|
||||
75
internal/admin/handler_version.go
Normal file
75
internal/admin/handler_version.go
Normal 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)
|
||||
}
|
||||
@@ -65,7 +65,6 @@ func toAccount(m map[string]any) config.Account {
|
||||
Email: email,
|
||||
Mobile: mobile,
|
||||
Password: fieldString(m, "password"),
|
||||
Token: fieldString(m, "token"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
109
internal/admin/token_runtime_http_test.go
Normal file
109
internal/admin/token_runtime_http_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,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 == "" {
|
||||
|
||||
@@ -73,22 +73,31 @@ func TestGoCompatToolcallFixtures(t *testing.T) {
|
||||
mustLoadJSON(t, fixturePath, &fixture)
|
||||
|
||||
var expected struct {
|
||||
Calls []util.ParsedToolCall `json:"calls"`
|
||||
Calls []util.ParsedToolCall `json:"calls"`
|
||||
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
|
||||
RejectedByPolicy bool `json:"rejectedByPolicy"`
|
||||
RejectedToolNames []string `json:"rejectedToolNames"`
|
||||
}
|
||||
mustLoadJSON(t, expectedPath, &expected)
|
||||
|
||||
var got []util.ParsedToolCall
|
||||
var got util.ToolCallParseResult
|
||||
switch strings.ToLower(strings.TrimSpace(fixture.Mode)) {
|
||||
case "standalone":
|
||||
got = util.ParseStandaloneToolCalls(fixture.Text, fixture.ToolNames)
|
||||
got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
default:
|
||||
got = util.ParseToolCalls(fixture.Text, fixture.ToolNames)
|
||||
got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
}
|
||||
if len(got) == 0 && len(expected.Calls) == 0 {
|
||||
continue
|
||||
if got.Calls == nil {
|
||||
got.Calls = []util.ParsedToolCall{}
|
||||
}
|
||||
if !reflect.DeepEqual(got, expected.Calls) {
|
||||
t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected.Calls)
|
||||
if got.RejectedToolNames == nil {
|
||||
got.RejectedToolNames = []string{}
|
||||
}
|
||||
if !reflect.DeepEqual(got.Calls, expected.Calls) ||
|
||||
got.SawToolCallSyntax != expected.SawToolCallSyntax ||
|
||||
got.RejectedByPolicy != expected.RejectedByPolicy ||
|
||||
!reflect.DeepEqual(got.RejectedToolNames, expected.RejectedToolNames) {
|
||||
t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
||||
if strings.TrimSpace(c.Embeddings.Provider) != "" {
|
||||
m["embeddings"] = c.Embeddings
|
||||
}
|
||||
m["auto_delete"] = c.AutoDelete
|
||||
if c.VercelSyncHash != "" {
|
||||
m["_vercel_sync_hash"] = c.VercelSyncHash
|
||||
}
|
||||
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
||||
if err := json.Unmarshal(v, &c.Embeddings); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "auto_delete":
|
||||
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
}
|
||||
case "_vercel_sync_hash":
|
||||
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
|
||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
|
||||
Toolcall: c.Toolcall,
|
||||
Responses: c.Responses,
|
||||
Embeddings: c.Embeddings,
|
||||
AutoDelete: c.AutoDelete,
|
||||
VercelSyncHash: c.VercelSyncHash,
|
||||
VercelSyncTime: c.VercelSyncTime,
|
||||
AdditionalFields: map[string]any{},
|
||||
|
||||
@@ -12,6 +12,7 @@ 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"`
|
||||
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
||||
AdditionalFields map[string]any `json:"-"`
|
||||
@@ -25,6 +26,32 @@ type Account struct {
|
||||
TestStatus string `json:"test_status,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 {
|
||||
WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"`
|
||||
}
|
||||
@@ -53,3 +80,7 @@ type ResponsesConfig struct {
|
||||
type EmbeddingsConfig struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
type AutoDeleteConfig struct {
|
||||
Sessions bool `json:"sessions"`
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"os"
|
||||
"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 +25,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 +89,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ func loadConfig() (Config, bool, error) {
|
||||
}
|
||||
if rawCfg != "" {
|
||||
cfg, err := parseConfigString(rawCfg)
|
||||
cfg.ClearAccountTokens()
|
||||
cfg.DropInvalidAccounts()
|
||||
return cfg, true, err
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ func loadConfig() (Config, bool, error) {
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return Config{}, false, err
|
||||
}
|
||||
cfg.DropInvalidAccounts()
|
||||
if IsVercel() {
|
||||
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
|
||||
return cfg, true, nil
|
||||
@@ -161,7 +164,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 +178,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 +204,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
|
||||
}
|
||||
|
||||
@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
|
||||
}
|
||||
return defaultSize
|
||||
}
|
||||
|
||||
func (s *Store) AutoDeleteSessions() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.AutoDelete.Sessions
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code := intFrom(resp["code"])
|
||||
if status == http.StatusOK && code == 0 {
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
sessionID, _ := bizData["id"].(string)
|
||||
@@ -71,10 +71,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
|
||||
return sessionID, nil
|
||||
}
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
|
||||
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, msg) && !refreshed {
|
||||
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
refreshed = true
|
||||
continue
|
||||
@@ -96,6 +95,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
|
||||
@@ -104,8 +104,8 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
code := intFrom(resp["code"])
|
||||
if status == http.StatusOK && code == 0 {
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
challenge, _ := bizData["challenge"].(map[string]any)
|
||||
@@ -116,15 +116,16 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
|
||||
}
|
||||
return BuildPowHeader(challenge, answer)
|
||||
}
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
|
||||
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, msg) {
|
||||
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
refreshed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
@@ -143,15 +144,75 @@ func (c *Client) authHeaders(token string) map[string]string {
|
||||
return headers
|
||||
}
|
||||
|
||||
func isTokenInvalid(status int, code int, msg string) bool {
|
||||
msg = strings.ToLower(msg)
|
||||
func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
|
||||
msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
if code == 40001 || code == 40002 || code == 40003 {
|
||||
if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized")
|
||||
return strings.Contains(msg, "token") ||
|
||||
strings.Contains(msg, "unauthorized") ||
|
||||
strings.Contains(msg, "expired") ||
|
||||
strings.Contains(msg, "not login") ||
|
||||
strings.Contains(msg, "login required") ||
|
||||
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)
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizCode = intFrom(data["biz_code"])
|
||||
bizMsg, _ = data["biz_msg"].(string)
|
||||
if strings.TrimSpace(bizMsg) == "" {
|
||||
if bizData, ok := data["biz_data"].(map[string]any); ok {
|
||||
bizMsg, _ = bizData["msg"].(string)
|
||||
}
|
||||
}
|
||||
return code, bizCode, msg, bizMsg
|
||||
}
|
||||
|
||||
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {
|
||||
|
||||
27
internal/deepseek/client_auth_refresh_test.go
Normal file
27
internal/deepseek/client_auth_refresh_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -62,3 +62,51 @@ 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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := doer.Do(req)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
|
||||
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if reqErr != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req2.Header.Set(k, v)
|
||||
}
|
||||
resp, err = c.fallback.Do(req2)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payloadBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
out := map[string]any{}
|
||||
if len(payloadBytes) > 0 {
|
||||
if err := json.Unmarshal(payloadBytes, &out); err != nil {
|
||||
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
|
||||
}
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
256
internal/deepseek/client_session.go
Normal file
256
internal/deepseek/client_session.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
// SessionInfo 会话信息
|
||||
type SessionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
TitleType string `json:"title_type"`
|
||||
Pinned bool `json:"pinned"`
|
||||
UpdatedAt float64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionStats 会话统计结果
|
||||
type SessionStats struct {
|
||||
AccountID string // 账号标识 (email 或 mobile)
|
||||
FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
|
||||
PinnedCount int // 置顶会话数量
|
||||
HasMore bool // 是否还有更多页
|
||||
Success bool // 请求是否成功
|
||||
ErrorMessage string // 错误信息
|
||||
}
|
||||
|
||||
// GetSessionCount 获取单个账号的会话数量
|
||||
func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
|
||||
stats := &SessionStats{
|
||||
AccountID: a.AccountID,
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
|
||||
// 构建请求 URL
|
||||
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
chatSessions, _ := bizData["chat_sessions"].([]any)
|
||||
hasMore, _ := bizData["has_more"].(bool)
|
||||
|
||||
stats.FirstPageCount = len(chatSessions)
|
||||
stats.HasMore = hasMore
|
||||
stats.Success = true
|
||||
|
||||
// 统计置顶会话数量
|
||||
for _, session := range chatSessions {
|
||||
if s, ok := session.(map[string]any); ok {
|
||||
if pinned, ok := s["pinned"].(bool); ok && pinned {
|
||||
stats.PinnedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
|
||||
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID)
|
||||
|
||||
if a.UseConfigToken {
|
||||
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
refreshed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
stats.Success = false
|
||||
stats.ErrorMessage = "get session count failed after retries"
|
||||
return stats, errors.New(stats.ErrorMessage)
|
||||
}
|
||||
|
||||
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
|
||||
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
|
||||
headers := c.authHeaders(token)
|
||||
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if status != http.StatusOK || code != 0 || bizCode != 0 {
|
||||
if strings.TrimSpace(bizMsg) != "" {
|
||||
msg = bizMsg
|
||||
}
|
||||
return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
|
||||
}
|
||||
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
chatSessions, _ := bizData["chat_sessions"].([]any)
|
||||
hasMore, _ := bizData["has_more"].(bool)
|
||||
|
||||
stats := &SessionStats{
|
||||
FirstPageCount: len(chatSessions),
|
||||
HasMore: hasMore,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
// 统计置顶会话数量
|
||||
for _, session := range chatSessions {
|
||||
if s, ok := session.(map[string]any); ok {
|
||||
if pinned, ok := s["pinned"].(bool); ok && pinned {
|
||||
stats.PinnedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetSessionCountAll 获取所有账号的会话数量统计
|
||||
func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
|
||||
accounts := c.Store.Accounts()
|
||||
results := make([]*SessionStats, 0, len(accounts))
|
||||
|
||||
for _, acc := range accounts {
|
||||
token := acc.Token
|
||||
accountID := acc.Email
|
||||
if accountID == "" {
|
||||
accountID = acc.Mobile
|
||||
}
|
||||
|
||||
// 如果没有 token,尝试登录获取
|
||||
if token == "" {
|
||||
var err error
|
||||
token, err = c.Login(ctx, acc)
|
||||
if err != nil {
|
||||
results = append(results, &SessionStats{
|
||||
AccountID: accountID,
|
||||
Success: false,
|
||||
ErrorMessage: fmt.Sprintf("login failed: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := c.GetSessionCountForToken(ctx, token)
|
||||
if err != nil {
|
||||
results = append(results, &SessionStats{
|
||||
AccountID: accountID,
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
stats.AccountID = accountID
|
||||
results = append(results, stats)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// FetchSessionPage 获取会话列表(支持分页)
|
||||
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
|
||||
// 构建请求 URL
|
||||
params := url.Values{}
|
||||
params.Set("lte_cursor.pinned", "false")
|
||||
if cursor != "" {
|
||||
params.Set("lte_cursor", cursor)
|
||||
}
|
||||
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
|
||||
|
||||
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
code := intFrom(resp["code"])
|
||||
if status != http.StatusOK || code != 0 {
|
||||
msg, _ := resp["msg"].(string)
|
||||
return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
|
||||
}
|
||||
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
bizData, _ := data["biz_data"].(map[string]any)
|
||||
chatSessions, _ := bizData["chat_sessions"].([]any)
|
||||
hasMore, _ := bizData["has_more"].(bool)
|
||||
|
||||
sessions := make([]SessionInfo, 0, len(chatSessions))
|
||||
for _, s := range chatSessions {
|
||||
if m, ok := s.(map[string]any); ok {
|
||||
session := SessionInfo{
|
||||
ID: stringFromMap(m, "id"),
|
||||
Title: stringFromMap(m, "title"),
|
||||
TitleType: stringFromMap(m, "title_type"),
|
||||
Pinned: boolFromMap(m, "pinned"),
|
||||
UpdatedAt: floatFromMap(m, "updated_at"),
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
}
|
||||
|
||||
return sessions, hasMore, nil
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func stringFromMap(m map[string]any, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolFromMap(m map[string]any, key string) bool {
|
||||
if v, ok := m[key].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func floatFromMap(m map[string]any, key string) float64 {
|
||||
if v, ok := m[key].(float64); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
155
internal/deepseek/client_session_delete.go
Normal file
155
internal/deepseek/client_session_delete.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package deepseek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
"ds2api/internal/config"
|
||||
)
|
||||
|
||||
// DeleteSessionResult 删除会话结果
|
||||
type DeleteSessionResult struct {
|
||||
SessionID string // 会话 ID
|
||||
Success bool // 是否成功
|
||||
ErrorMessage string // 错误信息
|
||||
}
|
||||
|
||||
// DeleteSession 删除单个会话
|
||||
func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = c.maxRetries
|
||||
}
|
||||
|
||||
result := &DeleteSessionResult{
|
||||
SessionID: sessionID,
|
||||
}
|
||||
|
||||
if sessionID == "" {
|
||||
result.ErrorMessage = "session_id is required"
|
||||
return result, errors.New(result.ErrorMessage)
|
||||
}
|
||||
|
||||
attempts := 0
|
||||
refreshed := false
|
||||
|
||||
for attempts < maxAttempts {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
|
||||
payload := map[string]any{
|
||||
"chat_session_id": sessionID,
|
||||
}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
|
||||
code, bizCode, msg, bizMsg := extractResponseStatus(resp)
|
||||
if status == http.StatusOK && code == 0 && bizCode == 0 {
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
|
||||
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "session_id", sessionID)
|
||||
|
||||
if a.UseConfigToken {
|
||||
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
|
||||
if c.Auth.RefreshToken(ctx, a) {
|
||||
refreshed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if c.Auth.SwitchAccount(ctx, a) {
|
||||
refreshed = false
|
||||
attempts++
|
||||
continue
|
||||
}
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
|
||||
result.Success = false
|
||||
result.ErrorMessage = "delete session failed after retries"
|
||||
return result, errors.New(result.ErrorMessage)
|
||||
}
|
||||
|
||||
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
|
||||
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
|
||||
result := &DeleteSessionResult{
|
||||
SessionID: sessionID,
|
||||
}
|
||||
|
||||
if sessionID == "" {
|
||||
result.ErrorMessage = "session_id is required"
|
||||
return result, errors.New(result.ErrorMessage)
|
||||
}
|
||||
|
||||
headers := c.authHeaders(token)
|
||||
payload := map[string]any{
|
||||
"chat_session_id": sessionID,
|
||||
}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
|
||||
if err != nil {
|
||||
result.ErrorMessage = err.Error()
|
||||
return result, err
|
||||
}
|
||||
|
||||
code := intFrom(resp["code"])
|
||||
if status != http.StatusOK || code != 0 {
|
||||
msg, _ := resp["msg"].(string)
|
||||
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
|
||||
return result, errors.New(result.ErrorMessage)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteAllSessions 删除所有会话(谨慎使用)
|
||||
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
|
||||
headers := c.authHeaders(a.DeepSeekToken)
|
||||
payload := map[string]any{}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
code := intFrom(resp["code"])
|
||||
if status != http.StatusOK || code != 0 {
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
|
||||
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
|
||||
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
|
||||
headers := c.authHeaders(token)
|
||||
payload := map[string]any{}
|
||||
|
||||
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
|
||||
if err != nil {
|
||||
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
code := intFrom(resp["code"])
|
||||
if status != http.StatusOK || code != 0 {
|
||||
msg, _ := resp["msg"].(string)
|
||||
config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
|
||||
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
||||
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
||||
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
|
||||
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
|
||||
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
|
||||
)
|
||||
|
||||
var defaultBaseHeaders = map[string]string{
|
||||
|
||||
@@ -8,15 +8,15 @@ import (
|
||||
)
|
||||
|
||||
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := util.ParseStandaloneToolCalls(finalText, toolNames)
|
||||
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
finishReason := "stop"
|
||||
messageObj := map[string]any{"role": "assistant", "content": finalText}
|
||||
if strings.TrimSpace(finalThinking) != "" {
|
||||
messageObj["reasoning_content"] = finalThinking
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
if len(detected.Calls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected)
|
||||
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
|
||||
messageObj["content"] = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
// Strict mode: only standalone, structured tool-call payloads are treated
|
||||
// as executable tool calls.
|
||||
detected := util.ParseStandaloneToolCalls(finalText, toolNames)
|
||||
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
exposedOutputText := finalText
|
||||
output := make([]any, 0, 2)
|
||||
if len(detected) > 0 {
|
||||
if len(detected.Calls) > 0 {
|
||||
exposedOutputText = ""
|
||||
output = append(output, toResponsesFunctionCallItems(detected)...)
|
||||
output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
|
||||
} else {
|
||||
content := make([]any, 0, 2)
|
||||
if finalThinking != "" {
|
||||
|
||||
@@ -71,6 +71,19 @@ func BuildResponsesTextDeltaPayload(responseID, itemID string, outputIndex, cont
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func BuildResponsesTextDonePayload(responseID, itemID string, outputIndex, contentIndex int, text string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "response.output_text.done",
|
||||
"id": responseID,
|
||||
"response_id": responseID,
|
||||
"item_id": itemID,
|
||||
"output_index": outputIndex,
|
||||
"content_index": contentIndex,
|
||||
"text": text,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildResponsesReasoningDeltaPayload(responseID, delta string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "response.reasoning.delta",
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestBuildResponseObjectToolCallsFollowChatShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) {
|
||||
func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testing.T) {
|
||||
obj := BuildResponseObject(
|
||||
"resp_test",
|
||||
"gpt-4o",
|
||||
@@ -56,20 +56,20 @@ func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) {
|
||||
)
|
||||
|
||||
outputText, _ := obj["output_text"].(string)
|
||||
if outputText == "" {
|
||||
t.Fatalf("expected output_text preserved for mixed prose payload")
|
||||
if outputText != "" {
|
||||
t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
|
||||
}
|
||||
output, _ := obj["output"].([]any)
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("expected one message output item, got %#v", obj["output"])
|
||||
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
|
||||
}
|
||||
first, _ := output[0].(map[string]any)
|
||||
if first["type"] != "message" {
|
||||
t.Fatalf("expected message output type, got %#v", first["type"])
|
||||
if first["type"] != "function_call" {
|
||||
t.Fatalf("expected function_call output type, got %#v", first["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
|
||||
func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
|
||||
obj := BuildResponseObject(
|
||||
"resp_test",
|
||||
"gpt-4o",
|
||||
@@ -80,16 +80,16 @@ func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
|
||||
)
|
||||
|
||||
outputText, _ := obj["output_text"].(string)
|
||||
if outputText == "" {
|
||||
t.Fatalf("expected output_text preserved for fenced example")
|
||||
if outputText != "" {
|
||||
t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
|
||||
}
|
||||
output, _ := obj["output"].([]any)
|
||||
if len(output) != 1 {
|
||||
t.Fatalf("expected one message output item, got %#v", obj["output"])
|
||||
t.Fatalf("expected one function_call output item, got %#v", obj["output"])
|
||||
}
|
||||
first, _ := output[0].(map[string]any)
|
||||
if first["type"] != "message" {
|
||||
t.Fatalf("expected message output type, got %#v", first["type"])
|
||||
if first["type"] != "function_call" {
|
||||
t.Fatalf("expected function_call output type, got %#v", first["type"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
extractToolNames,
|
||||
createToolSieveState,
|
||||
processToolSieveChunk,
|
||||
flushToolSieve,
|
||||
parseStandaloneToolCalls,
|
||||
formatOpenAIStreamToolCalls,
|
||||
} = require('../helpers/stream-tool-sieve');
|
||||
const {
|
||||
BASE_HEADERS,
|
||||
} = require('../shared/deepseek-constants');
|
||||
|
||||
const {
|
||||
writeOpenAIError,
|
||||
} = require('./error_shape');
|
||||
const {
|
||||
parseChunkForContent,
|
||||
isCitation,
|
||||
} = require('./sse_parse');
|
||||
const {
|
||||
buildUsage,
|
||||
} = require('./token_usage');
|
||||
const { BASE_HEADERS } = require('../shared/deepseek-constants');
|
||||
const { writeOpenAIError } = require('./error_shape');
|
||||
const { parseChunkForContent, isCitation } = require('./sse_parse');
|
||||
const { buildUsage } = require('./token_usage');
|
||||
const {
|
||||
resolveToolcallPolicy,
|
||||
formatIncrementalToolCallDeltas,
|
||||
filterIncrementalToolCallDeltasByAllowed,
|
||||
} = require('./toolcall_policy');
|
||||
const {
|
||||
createChatCompletionEmitter,
|
||||
} = require('./stream_emitter');
|
||||
const { createChatCompletionEmitter } = require('./stream_emitter');
|
||||
const {
|
||||
asString,
|
||||
isAbortError,
|
||||
@@ -57,6 +46,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
const searchEnabled = toBool(prep.body.search_enabled);
|
||||
const toolPolicy = resolveToolcallPolicy(prep.body, payload.tools);
|
||||
const toolNames = toolPolicy.toolNames;
|
||||
const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas;
|
||||
|
||||
if (!model || !leaseID || !deepseekToken || !powHeader || !completionPayload) {
|
||||
writeOpenAIError(res, 500, 'invalid vercel prepare response');
|
||||
@@ -132,6 +122,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
const toolSieveState = createToolSieveState();
|
||||
let toolCallsEmitted = false;
|
||||
const streamToolCallIDs = new Map();
|
||||
const streamToolNames = new Map();
|
||||
const decoder = new TextDecoder();
|
||||
reader = completionRes.body.getReader();
|
||||
let buffered = '';
|
||||
@@ -255,6 +246,18 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
}
|
||||
const events = processToolSieveChunk(toolSieveState, p.text, toolNames);
|
||||
for (const evt of events) {
|
||||
if (evt.type === 'tool_call_deltas') {
|
||||
if (!emitEarlyToolDeltas) {
|
||||
continue;
|
||||
}
|
||||
const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames);
|
||||
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
|
||||
if (formatted.length > 0) {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatted });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (evt.type === 'tool_calls') {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
|
||||
|
||||
@@ -2,28 +2,33 @@
|
||||
|
||||
const {
|
||||
toStringSafe,
|
||||
looksLikeToolExampleContext,
|
||||
} = require('./state');
|
||||
const {
|
||||
stripFencedCodeBlocks,
|
||||
buildToolCallCandidates,
|
||||
parseToolCallsPayload,
|
||||
parseMarkupToolCalls,
|
||||
parseTextKVToolCalls,
|
||||
} = require('./parse_payload');
|
||||
|
||||
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -34,26 +39,36 @@ function parseToolCalls(text, toolNames) {
|
||||
|
||||
function parseToolCallsDetailed(text, toolNames) {
|
||||
const result = emptyParseResult();
|
||||
if (!toStringSafe(text)) {
|
||||
const normalized = toStringSafe(text);
|
||||
if (!normalized) {
|
||||
return result;
|
||||
}
|
||||
const sanitized = stripFencedCodeBlocks(text);
|
||||
if (!toStringSafe(sanitized)) {
|
||||
return result;
|
||||
}
|
||||
result.sawToolCallSyntax = sanitized.toLowerCase().includes('tool_calls');
|
||||
result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
|
||||
|
||||
const candidates = buildToolCallCandidates(sanitized);
|
||||
const candidates = buildToolCallCandidates(normalized);
|
||||
let parsed = [];
|
||||
for (const c of candidates) {
|
||||
parsed = parseToolCallsPayload(c);
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseMarkupToolCalls(c);
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseTextKVToolCalls(c);
|
||||
}
|
||||
if (parsed.length > 0) {
|
||||
result.sawToolCallSyntax = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return result;
|
||||
parsed = parseMarkupToolCalls(normalized);
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseTextKVToolCalls(normalized);
|
||||
if (parsed.length === 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
result.sawToolCallSyntax = true;
|
||||
}
|
||||
|
||||
const filtered = filterToolCallsDetailed(parsed, toolNames);
|
||||
@@ -73,17 +88,29 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
|
||||
if (!trimmed) {
|
||||
return result;
|
||||
}
|
||||
if (looksLikeToolExampleContext(trimmed)) {
|
||||
return result;
|
||||
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
|
||||
const candidates = buildToolCallCandidates(trimmed);
|
||||
let parsed = [];
|
||||
for (const c of candidates) {
|
||||
parsed = parseToolCallsPayload(c);
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseMarkupToolCalls(c);
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseTextKVToolCalls(c);
|
||||
}
|
||||
if (parsed.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.sawToolCallSyntax = trimmed.toLowerCase().includes('tool_calls');
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const parsed = parseToolCallsPayload(trimmed);
|
||||
if (parsed.length === 0) {
|
||||
return result;
|
||||
parsed = parseMarkupToolCalls(trimmed);
|
||||
if (parsed.length === 0) {
|
||||
parsed = parseTextKVToolCalls(trimmed);
|
||||
if (parsed.length === 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.sawToolCallSyntax = true;
|
||||
@@ -146,7 +173,7 @@ function filterToolCallsDetailed(parsed, toolNames) {
|
||||
if (allowed.has(tc.name)) {
|
||||
matchedName = tc.name;
|
||||
} else {
|
||||
matchedName = allowedCanonical.get(tc.name.toLowerCase()) || '';
|
||||
matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical);
|
||||
}
|
||||
if (!matchedName) {
|
||||
if (!seenRejected.has(tc.name)) {
|
||||
@@ -163,6 +190,46 @@ function filterToolCallsDetailed(parsed, toolNames) {
|
||||
return { calls, rejectedToolNames: rejected };
|
||||
}
|
||||
|
||||
function resolveAllowedToolName(name, allowed, allowedCanonical) {
|
||||
const normalizedName = toStringSafe(name).trim();
|
||||
if (!normalizedName) {
|
||||
return '';
|
||||
}
|
||||
if (allowed.has(normalizedName)) {
|
||||
return normalizedName;
|
||||
}
|
||||
const lower = normalizedName.toLowerCase();
|
||||
if (allowedCanonical.has(lower)) {
|
||||
return allowedCanonical.get(lower);
|
||||
}
|
||||
const idx = lower.lastIndexOf('.');
|
||||
if (idx >= 0 && idx < lower.length - 1) {
|
||||
const tail = lower.slice(idx + 1);
|
||||
if (allowedCanonical.has(tail)) {
|
||||
return allowedCanonical.get(tail);
|
||||
}
|
||||
}
|
||||
const loose = lower.replace(TOOL_NAME_LOOSE_PATTERN, '');
|
||||
if (!loose) {
|
||||
return '';
|
||||
}
|
||||
for (const [candidateLower, canonical] of allowedCanonical.entries()) {
|
||||
if (candidateLower.replace(TOOL_NAME_LOOSE_PATTERN, '') === loose) {
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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:');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractToolNames,
|
||||
parseToolCalls,
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
const TOOL_CALL_PATTERN = /\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}/s;
|
||||
const TOOL_CALL_MARKUP_BLOCK_PATTERN = /<(?:[a-z0-9_:-]+:)?(tool_call|function_call|invoke)\b([^>]*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
|
||||
const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/>/gi;
|
||||
const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
|
||||
const TOOL_CALL_MARKUP_ATTR_PATTERN = /(name|function|tool)\s*=\s*"([^"]+)"/i;
|
||||
const TOOL_CALL_MARKUP_NAME_PATTERNS = [
|
||||
/<(?:[a-z0-9_:-]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?name>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?function\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function>/i,
|
||||
];
|
||||
const TOOL_CALL_MARKUP_ARGS_PATTERNS = [
|
||||
/<(?:[a-z0-9_:-]+:)?input\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?input>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?arguments>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?argument\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?argument>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameters>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?parameter>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
|
||||
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
|
||||
];
|
||||
const TEXT_KV_NAME_PATTERN = /function\.name:\s*([a-zA-Z0-9_.-]+)/gi;
|
||||
|
||||
const {
|
||||
toStringSafe,
|
||||
@@ -103,6 +121,153 @@ function parseToolCallsPayload(payload) {
|
||||
return one ? [one] : [];
|
||||
}
|
||||
|
||||
function parseMarkupToolCalls(text) {
|
||||
const raw = toStringSafe(text).trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const m of raw.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) {
|
||||
const parsed = parseMarkupSingleToolCall(toStringSafe(m[2]).trim(), toStringSafe(m[3]).trim());
|
||||
if (parsed) {
|
||||
out.push(parsed);
|
||||
}
|
||||
}
|
||||
for (const m of raw.matchAll(TOOL_CALL_MARKUP_SELFCLOSE_PATTERN)) {
|
||||
const parsed = parseMarkupSingleToolCall(toStringSafe(m[1]).trim(), '');
|
||||
if (parsed) {
|
||||
out.push(parsed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseTextKVToolCalls(text) {
|
||||
const raw = toStringSafe(text);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
const matches = [...raw.matchAll(TEXT_KV_NAME_PATTERN)];
|
||||
if (matches.length === 0) {
|
||||
return out;
|
||||
}
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const match = matches[i];
|
||||
const name = toStringSafe(match[1]).trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const nameEnd = match.index + toStringSafe(match[0]).length;
|
||||
const searchEnd = i + 1 < matches.length ? matches[i + 1].index : raw.length;
|
||||
const searchArea = raw.slice(nameEnd, searchEnd);
|
||||
const argIdx = searchArea.indexOf('function.arguments:');
|
||||
if (argIdx < 0) {
|
||||
continue;
|
||||
}
|
||||
const argStart = nameEnd + argIdx + 'function.arguments:'.length;
|
||||
const bracePos = raw.slice(argStart, searchEnd).indexOf('{');
|
||||
if (bracePos < 0) {
|
||||
continue;
|
||||
}
|
||||
const objStart = argStart + bracePos;
|
||||
const obj = extractJSONObjectFrom(raw, objStart);
|
||||
if (!obj.ok) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
name,
|
||||
input: parseToolCallInput(raw.slice(objStart, obj.end)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMarkupSingleToolCall(attrs, inner) {
|
||||
const embedded = parseToolCallsPayload(inner);
|
||||
if (embedded.length > 0) {
|
||||
return embedded[0];
|
||||
}
|
||||
let name = '';
|
||||
const attrMatch = attrs.match(TOOL_CALL_MARKUP_ATTR_PATTERN);
|
||||
if (attrMatch && attrMatch[2]) {
|
||||
name = toStringSafe(attrMatch[2]).trim();
|
||||
}
|
||||
if (!name) {
|
||||
name = stripTagText(findMarkupTagValue(inner, TOOL_CALL_MARKUP_NAME_PATTERNS));
|
||||
}
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let input = {};
|
||||
const argsRaw = findMarkupTagValue(inner, TOOL_CALL_MARKUP_ARGS_PATTERNS);
|
||||
if (argsRaw) {
|
||||
input = parseMarkupInput(argsRaw);
|
||||
} else {
|
||||
const kv = parseMarkupKVObject(inner);
|
||||
if (Object.keys(kv).length > 0) {
|
||||
input = kv;
|
||||
}
|
||||
}
|
||||
return { name, input };
|
||||
}
|
||||
|
||||
function parseMarkupInput(raw) {
|
||||
const s = toStringSafe(raw).trim();
|
||||
if (!s) {
|
||||
return {};
|
||||
}
|
||||
const parsed = parseToolCallInput(s);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
const kv = parseMarkupKVObject(s);
|
||||
if (Object.keys(kv).length > 0) {
|
||||
return kv;
|
||||
}
|
||||
return { _raw: stripTagText(s) };
|
||||
}
|
||||
|
||||
function parseMarkupKVObject(text) {
|
||||
const raw = toStringSafe(text).trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const out = {};
|
||||
for (const m of raw.matchAll(TOOL_CALL_MARKUP_KV_PATTERN)) {
|
||||
const key = toStringSafe(m[1]).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const valueRaw = stripTagText(m[2]);
|
||||
if (!valueRaw) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out[key] = JSON.parse(valueRaw);
|
||||
} catch (_err) {
|
||||
out[key] = valueRaw;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripTagText(text) {
|
||||
return toStringSafe(text).replace(/<[^>]+>/g, ' ').trim();
|
||||
}
|
||||
|
||||
function findMarkupTagValue(text, patterns) {
|
||||
const source = toStringSafe(text);
|
||||
for (const p of patterns) {
|
||||
const m = source.match(p);
|
||||
if (m && m[1]) {
|
||||
return toStringSafe(m[1]);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseToolCallList(v) {
|
||||
if (!Array.isArray(v)) {
|
||||
return [];
|
||||
@@ -193,4 +358,6 @@ module.exports = {
|
||||
stripFencedCodeBlocks,
|
||||
buildToolCallCandidates,
|
||||
parseToolCallsPayload,
|
||||
parseMarkupToolCalls,
|
||||
parseTextKVToolCalls,
|
||||
};
|
||||
|
||||
@@ -21,22 +21,14 @@ function processToolSieveChunk(state, chunk, toolNames) {
|
||||
}
|
||||
const events = [];
|
||||
|
||||
if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) {
|
||||
const pending = state.pending || '';
|
||||
if (pending.trim() !== '') {
|
||||
const content = (state.pendingToolRaw || '') + pending;
|
||||
state.pending = '';
|
||||
state.pendingToolRaw = '';
|
||||
state.pendingToolCalls = [];
|
||||
noteText(state, content);
|
||||
events.push({ type: 'text', text: content });
|
||||
} else {
|
||||
return 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 });
|
||||
state.pendingToolRaw = '';
|
||||
state.pendingToolCalls = [];
|
||||
continue;
|
||||
}
|
||||
if (state.capturing) {
|
||||
if (state.pending) {
|
||||
state.capture += state.pending;
|
||||
@@ -54,6 +46,9 @@ function processToolSieveChunk(state, chunk, toolNames) {
|
||||
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
|
||||
state.pendingToolRaw = captured;
|
||||
state.pendingToolCalls = consumed.calls;
|
||||
if (consumed.suffix) {
|
||||
state.pending = consumed.suffix + state.pending;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (consumed.prefix) {
|
||||
@@ -173,19 +168,34 @@ function findToolSegmentStart(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) {
|
||||
const keyIdx = lower.indexOf('tool_calls', offset);
|
||||
if (keyIdx < 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))) {
|
||||
return candidateStart;
|
||||
}
|
||||
offset = keyIdx + 'tool_calls'.length;
|
||||
offset = keyIdx + matchedKeyword.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,20 +205,28 @@ function consumeToolCapture(state, toolNames) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
const lower = captured.toLowerCase();
|
||||
const keyIdx = lower.indexOf('tool_calls');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyIdx < 0) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
const start = captured.slice(0, keyIdx).lastIndexOf('{');
|
||||
if (start < 0) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
const obj = extractJSONObjectFrom(captured, start);
|
||||
const actualStart = start >= 0 ? start : keyIdx;
|
||||
|
||||
const obj = extractJSONObjectFrom(captured, actualStart);
|
||||
if (!obj.ok) {
|
||||
return { ready: false, prefix: '', calls: [], suffix: '' };
|
||||
}
|
||||
|
||||
const prefixPart = captured.slice(0, start);
|
||||
const prefixPart = captured.slice(0, actualStart);
|
||||
const suffixPart = captured.slice(obj.end);
|
||||
|
||||
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
|
||||
@@ -220,16 +238,7 @@ function consumeToolCapture(state, toolNames) {
|
||||
};
|
||||
}
|
||||
|
||||
if ((state.recentTextTail || '').trim() !== '' || prefixPart.trim() !== '' || suffixPart.trim() !== '') {
|
||||
return {
|
||||
ready: true,
|
||||
prefix: captured,
|
||||
calls: [],
|
||||
suffix: '',
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseStandaloneToolCallsDetailed(captured.slice(start, obj.end), toolNames);
|
||||
const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
|
||||
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
|
||||
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
|
||||
return {
|
||||
|
||||
@@ -51,6 +51,9 @@ func MessagesPrepare(messages []map[string]any) string {
|
||||
}
|
||||
|
||||
func NormalizeContent(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
@@ -64,11 +67,11 @@ func NormalizeContent(v any) string {
|
||||
typeStr, _ := m["type"].(string)
|
||||
typeStr = strings.ToLower(strings.TrimSpace(typeStr))
|
||||
if typeStr == "text" || typeStr == "output_text" || typeStr == "input_text" {
|
||||
if txt, ok := m["text"].(string); ok {
|
||||
if txt, ok := m["text"].(string); ok && txt != "" {
|
||||
parts = append(parts, txt)
|
||||
continue
|
||||
}
|
||||
if txt, ok := m["content"].(string); ok {
|
||||
if txt, ok := m["content"].(string); ok && txt != "" {
|
||||
parts = append(parts, txt)
|
||||
}
|
||||
}
|
||||
|
||||
32
internal/prompt/messages_test.go
Normal file
32
internal/prompt/messages_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package prompt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeContentNilReturnsEmpty(t *testing.T) {
|
||||
if got := NormalizeContent(nil); got != "" {
|
||||
t.Fatalf("expected empty string for nil content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesPrepareNilContentNoNullLiteral(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"role": "assistant", "content": nil},
|
||||
{"role": "user", "content": "ok"},
|
||||
}
|
||||
got := MessagesPrepare(messages)
|
||||
if got == "" {
|
||||
t.Fatalf("expected non-empty output")
|
||||
}
|
||||
if got == "null" {
|
||||
t.Fatalf("expected no null literal output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
|
||||
got := NormalizeContent([]any{
|
||||
map[string]any{"type": "text", "text": "", "content": "from-content"},
|
||||
})
|
||||
if got != "from-content" {
|
||||
t.Fatalf("expected fallback to content when text is empty, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func buildToolCallCandidates(text string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// best-effort extraction around "tool_calls" key in mixed text payloads.
|
||||
// best-effort extraction around tool call keywords in mixed text payloads.
|
||||
candidates = append(candidates, extractToolCallObjects(trimmed)...)
|
||||
|
||||
// best-effort object slice: from first '{' to last '}'
|
||||
@@ -57,25 +57,65 @@ func extractToolCallObjects(text string) []string {
|
||||
lower := strings.ToLower(text)
|
||||
out := []string{}
|
||||
offset := 0
|
||||
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
|
||||
for {
|
||||
idx := strings.Index(lower[offset:], "tool_calls")
|
||||
if idx < 0 {
|
||||
bestIdx := -1
|
||||
matchedKeyword := ""
|
||||
for _, kw := range keywords {
|
||||
idx := strings.Index(lower[offset:], kw)
|
||||
if idx >= 0 {
|
||||
absIdx := offset + idx
|
||||
if bestIdx < 0 || absIdx < bestIdx {
|
||||
bestIdx = absIdx
|
||||
matchedKeyword = kw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestIdx < 0 {
|
||||
break
|
||||
}
|
||||
idx += offset
|
||||
start := strings.LastIndex(text[:idx], "{")
|
||||
for start >= 0 {
|
||||
|
||||
idx := bestIdx
|
||||
// Avoid backtracking too far to prevent OOM on malicious or very long strings
|
||||
searchLimit := idx - 2000
|
||||
if searchLimit < offset {
|
||||
searchLimit = offset
|
||||
}
|
||||
|
||||
start := strings.LastIndex(text[searchLimit:idx], "{")
|
||||
if start >= 0 {
|
||||
start += searchLimit
|
||||
}
|
||||
|
||||
if start < 0 {
|
||||
offset = idx + len(matchedKeyword)
|
||||
continue
|
||||
}
|
||||
|
||||
foundObj := false
|
||||
for start >= searchLimit {
|
||||
candidate, end, ok := extractJSONObject(text, start)
|
||||
if ok {
|
||||
// Move forward to avoid repeatedly matching the same object.
|
||||
offset = end
|
||||
out = append(out, strings.TrimSpace(candidate))
|
||||
foundObj = true
|
||||
break
|
||||
}
|
||||
start = strings.LastIndex(text[:start], "{")
|
||||
// Try previous '{'
|
||||
if start > searchLimit {
|
||||
prevStart := strings.LastIndex(text[searchLimit:start], "{")
|
||||
if prevStart >= 0 {
|
||||
start = searchLimit + prevStart
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if start < 0 {
|
||||
offset = idx + len("tool_calls")
|
||||
|
||||
if !foundObj {
|
||||
offset = idx + len(matchedKeyword)
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -88,7 +128,12 @@ func extractJSONObject(text string, start int) (string, int, bool) {
|
||||
depth := 0
|
||||
quote := byte(0)
|
||||
escaped := false
|
||||
for i := start; i < len(text); i++ {
|
||||
// Limit scan length to avoid OOM on unclosed objects
|
||||
maxLen := start + 50000
|
||||
if maxLen > len(text) {
|
||||
maxLen = len(text)
|
||||
}
|
||||
for i := start; i < maxLen; i++ {
|
||||
ch := text[i]
|
||||
if quote != 0 {
|
||||
if escaped {
|
||||
|
||||
108
internal/util/toolcalls_input_parse.go
Normal file
108
internal/util/toolcalls_input_parse.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func parseToolCallInput(v any) map[string]any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return x
|
||||
case string:
|
||||
raw := strings.TrimSpace(x)
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
|
||||
repairPathLikeControlChars(parsed)
|
||||
return parsed
|
||||
}
|
||||
// Try to repair invalid backslashes (common in Windows paths output by models)
|
||||
repaired := repairInvalidJSONBackslashes(raw)
|
||||
if repaired != raw {
|
||||
if err := json.Unmarshal([]byte(repaired), &parsed); err == nil && parsed != nil {
|
||||
repairPathLikeControlChars(parsed)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
// Try to repair loose JSON in string argument as well
|
||||
repairedLoose := RepairLooseJSON(raw)
|
||||
if repairedLoose != raw {
|
||||
if err := json.Unmarshal([]byte(repairedLoose), &parsed); err == nil && parsed != nil {
|
||||
repairPathLikeControlChars(parsed)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return map[string]any{"_raw": raw}
|
||||
default:
|
||||
b, err := json.Marshal(x)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
|
||||
func repairPathLikeControlChars(m map[string]any) {
|
||||
for k, v := range m {
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
repairPathLikeControlChars(vv)
|
||||
case []any:
|
||||
for _, item := range vv {
|
||||
if child, ok := item.(map[string]any); ok {
|
||||
repairPathLikeControlChars(child)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if isPathLikeKey(k) && containsControlRune(vv) {
|
||||
m[k] = escapeControlRunes(vv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPathLikeKey(key string) bool {
|
||||
k := strings.ToLower(strings.TrimSpace(key))
|
||||
return strings.Contains(k, "path") || strings.Contains(k, "file")
|
||||
}
|
||||
|
||||
func containsControlRune(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func escapeControlRunes(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
79
internal/util/toolcalls_json_repair.go
Normal file
79
internal/util/toolcalls_json_repair.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func repairInvalidJSONBackslashes(s string) string {
|
||||
if !strings.Contains(s, "\\") {
|
||||
return s
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(s) + 10)
|
||||
runes := []rune(s)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
if runes[i] == '\\' {
|
||||
if i+1 < len(runes) {
|
||||
next := runes[i+1]
|
||||
switch next {
|
||||
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
||||
out.WriteRune('\\')
|
||||
out.WriteRune(next)
|
||||
i++
|
||||
continue
|
||||
case 'u':
|
||||
if i+5 < len(runes) {
|
||||
isHex := true
|
||||
for j := 1; j <= 4; j++ {
|
||||
r := runes[i+1+j]
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isHex {
|
||||
out.WriteRune('\\')
|
||||
out.WriteRune('u')
|
||||
for j := 1; j <= 4; j++ {
|
||||
out.WriteRune(runes[i+1+j])
|
||||
}
|
||||
i += 5
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not a valid escape sequence, double it
|
||||
out.WriteString("\\\\")
|
||||
} else {
|
||||
out.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
var unquotedKeyPattern = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
|
||||
|
||||
// missingArrayBracketsPattern identifies a sequence of two or more JSON objects separated by commas
|
||||
// that immediately follow a colon, which indicates a missing array bracket `[` `]`.
|
||||
// E.g., "key": {"a": 1}, {"b": 2} -> "key": [{"a": 1}, {"b": 2}]
|
||||
// NOTE: The pattern uses (?:[^{}]|\{[^{}]*\})* to support single-level nested {} objects,
|
||||
// which handles cases like {"content": "x", "input": {"q": "y"}}
|
||||
var missingArrayBracketsPattern = regexp.MustCompile(`(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)`)
|
||||
|
||||
func RepairLooseJSON(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
// 1. Replace unquoted keys: {key: -> {"key":
|
||||
s = unquotedKeyPattern.ReplaceAllString(s, `$1"$2":`)
|
||||
|
||||
// 2. Heuristic: Fix missing array brackets for list of objects
|
||||
// e.g., : {obj1}, {obj2} -> : [{obj1}, {obj2}]
|
||||
// This specifically addresses DeepSeek's "list hallucination"
|
||||
s = missingArrayBracketsPattern.ReplaceAllString(s, `$1[$2]`)
|
||||
|
||||
return s
|
||||
}
|
||||
161
internal/util/toolcalls_markup.go
Normal file
161
internal/util/toolcalls_markup.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var toolCallMarkupTagNames = []string{"tool_call", "function_call", "invoke"}
|
||||
var toolCallMarkupTagPatternByName = map[string]*regexp.Regexp{
|
||||
"tool_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_call\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?tool_call>`),
|
||||
"function_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_call\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?function_call>`),
|
||||
"invoke": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)>(.*?)</(?:[a-z0-9_:-]+:)?invoke>`),
|
||||
}
|
||||
var toolCallMarkupSelfClosingPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)/>`)
|
||||
var toolCallMarkupKVPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)>`)
|
||||
var toolCallMarkupAttrPattern = regexp.MustCompile(`(?is)(name|function|tool)\s*=\s*"([^"]+)"`)
|
||||
var anyTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
var toolCallMarkupNameTagNames = []string{"name", "function"}
|
||||
var toolCallMarkupNamePatternByTag = map[string]*regexp.Regexp{
|
||||
"name": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?name\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?name>`),
|
||||
"function": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?function>`),
|
||||
}
|
||||
var toolCallMarkupArgsTagNames = []string{"input", "arguments", "argument", "parameters", "parameter", "args", "params"}
|
||||
var toolCallMarkupArgsPatternByTag = map[string]*regexp.Regexp{
|
||||
"input": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?input\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?input>`),
|
||||
"arguments": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?arguments\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?arguments>`),
|
||||
"argument": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?argument\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?argument>`),
|
||||
"parameters": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameters\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?parameters>`),
|
||||
"parameter": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?parameter\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?parameter>`),
|
||||
"args": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?args\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?args>`),
|
||||
"params": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?params\b[^>]*>(.*?)</(?:[a-z0-9_:-]+:)?params>`),
|
||||
}
|
||||
|
||||
func parseMarkupToolCalls(text string) []ParsedToolCall {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]ParsedToolCall, 0)
|
||||
for _, tagName := range toolCallMarkupTagNames {
|
||||
pattern := toolCallMarkupTagPatternByName[tagName]
|
||||
for _, m := range pattern.FindAllStringSubmatch(trimmed, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
attrs := strings.TrimSpace(m[1])
|
||||
inner := strings.TrimSpace(m[2])
|
||||
if parsed := parseMarkupSingleToolCall(attrs, inner); parsed.Name != "" {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range toolCallMarkupSelfClosingPattern.FindAllStringSubmatch(trimmed, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
if parsed := parseMarkupSingleToolCall(strings.TrimSpace(m[1]), ""); parsed.Name != "" {
|
||||
out = append(out, parsed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall {
|
||||
if parsed := parseToolCallsPayload(inner); len(parsed) > 0 {
|
||||
return parsed[0]
|
||||
}
|
||||
|
||||
name := ""
|
||||
if m := toolCallMarkupAttrPattern.FindStringSubmatch(attrs); len(m) >= 3 {
|
||||
name = strings.TrimSpace(m[2])
|
||||
}
|
||||
if name == "" {
|
||||
name = findMarkupTagValue(inner, toolCallMarkupNameTagNames, toolCallMarkupNamePatternByTag)
|
||||
}
|
||||
if name == "" {
|
||||
return ParsedToolCall{}
|
||||
}
|
||||
|
||||
input := map[string]any{}
|
||||
if argsRaw := findMarkupTagValue(inner, toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" {
|
||||
input = parseMarkupInput(argsRaw)
|
||||
} else if kv := parseMarkupKVObject(inner); len(kv) > 0 {
|
||||
input = kv
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}
|
||||
}
|
||||
|
||||
func parseMarkupInput(raw string) map[string]any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
|
||||
return parsed
|
||||
}
|
||||
if kv := parseMarkupKVObject(raw); len(kv) > 0 {
|
||||
return kv
|
||||
}
|
||||
return map[string]any{"_raw": stripTagText(raw)}
|
||||
}
|
||||
|
||||
func parseMarkupKVObject(text string) map[string]any {
|
||||
matches := toolCallMarkupKVPattern.FindAllStringSubmatch(strings.TrimSpace(text), -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, m := range matches {
|
||||
if len(m) < 4 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(m[1])
|
||||
endKey := strings.TrimSpace(m[3])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(key, endKey) {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(stripTagText(m[2]))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
var jsonValue any
|
||||
if json.Unmarshal([]byte(value), &jsonValue) == nil {
|
||||
out[key] = jsonValue
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripTagText(text string) string {
|
||||
return strings.TrimSpace(anyTagPattern.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
func findMarkupTagValue(text string, tagNames []string, patternByTag map[string]*regexp.Regexp) string {
|
||||
for _, tag := range tagNames {
|
||||
pattern := patternByTag[tag]
|
||||
if pattern == nil {
|
||||
continue
|
||||
}
|
||||
if m := pattern.FindStringSubmatch(text); len(m) >= 2 {
|
||||
value := strings.TrimSpace(m[1])
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
33
internal/util/toolcalls_name_match.go
Normal file
33
internal/util/toolcalls_name_match.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||
if _, ok := allowed[name]; ok {
|
||||
return name
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
if canonical, ok := allowedCanonical[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
|
||||
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
|
||||
if loose == "" {
|
||||
return ""
|
||||
}
|
||||
for candidateLower, canonical := range allowedCanonical {
|
||||
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -2,12 +2,9 @@ package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
type ParsedToolCall struct {
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
@@ -19,7 +16,6 @@ type ToolCallParseResult struct {
|
||||
RejectedByPolicy bool
|
||||
RejectedToolNames []string
|
||||
}
|
||||
|
||||
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
return ParseToolCallsDetailed(text, availableToolNames).Calls
|
||||
}
|
||||
@@ -29,23 +25,36 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return result
|
||||
}
|
||||
text = stripFencedCodeBlocks(text)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return result
|
||||
}
|
||||
result.SawToolCallSyntax = strings.Contains(strings.ToLower(text), "tool_calls")
|
||||
result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
|
||||
|
||||
candidates := buildToolCallCandidates(text)
|
||||
var parsed []ParsedToolCall
|
||||
for _, candidate := range candidates {
|
||||
if tc := parseToolCallsPayload(candidate); len(tc) > 0 {
|
||||
tc := parseToolCallsPayload(candidate)
|
||||
if len(tc) == 0 {
|
||||
tc = parseXMLToolCalls(candidate)
|
||||
}
|
||||
if len(tc) == 0 {
|
||||
tc = parseMarkupToolCalls(candidate)
|
||||
}
|
||||
if len(tc) == 0 {
|
||||
tc = parseTextKVToolCalls(candidate)
|
||||
}
|
||||
if len(tc) > 0 {
|
||||
parsed = tc
|
||||
result.SawToolCallSyntax = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return result
|
||||
parsed = parseXMLToolCalls(text)
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseTextKVToolCalls(text)
|
||||
if len(parsed) == 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
result.SawToolCallSyntax = true
|
||||
}
|
||||
|
||||
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
|
||||
@@ -54,7 +63,6 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
|
||||
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall {
|
||||
return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls
|
||||
}
|
||||
@@ -65,28 +73,42 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
|
||||
if trimmed == "" {
|
||||
return result
|
||||
}
|
||||
if looksLikeToolExampleContext(trimmed) {
|
||||
return result
|
||||
}
|
||||
result.SawToolCallSyntax = strings.Contains(strings.ToLower(trimmed), "tool_calls")
|
||||
candidates := []string{trimmed}
|
||||
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
|
||||
candidates := buildToolCallCandidates(trimmed)
|
||||
var parsed []ParsedToolCall
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "{") && !strings.HasPrefix(candidate, "[") {
|
||||
continue
|
||||
parsed = parseToolCallsPayload(candidate)
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseXMLToolCalls(candidate)
|
||||
}
|
||||
if parsed := parseToolCallsPayload(candidate); len(parsed) > 0 {
|
||||
result.SawToolCallSyntax = true
|
||||
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
|
||||
result.Calls = calls
|
||||
result.RejectedToolNames = rejectedNames
|
||||
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
|
||||
return result
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseMarkupToolCalls(candidate)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseTextKVToolCalls(candidate)
|
||||
}
|
||||
if len(parsed) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseXMLToolCalls(trimmed)
|
||||
if len(parsed) == 0 {
|
||||
parsed = parseTextKVToolCalls(trimmed)
|
||||
if len(parsed) == 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
result.SawToolCallSyntax = true
|
||||
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
|
||||
result.Calls = calls
|
||||
result.RejectedToolNames = rejectedNames
|
||||
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -106,27 +128,32 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
rejectedSet := map[string]struct{}{}
|
||||
rejected := make([]string, 0, len(parsed))
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := rejectedSet[tc.Name]; ok {
|
||||
continue
|
||||
}
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
}
|
||||
rejected := make([]string, 0, len(rejectedSet))
|
||||
for name := range rejectedSet {
|
||||
rejected = append(rejected, name)
|
||||
rejected = append(rejected, tc.Name)
|
||||
}
|
||||
return nil, rejected
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(parsed))
|
||||
rejectedSet := map[string]struct{}{}
|
||||
rejected := make([]string, 0)
|
||||
for _, tc := range parsed {
|
||||
if tc.Name == "" {
|
||||
continue
|
||||
}
|
||||
matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical)
|
||||
if matchedName == "" {
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
if _, ok := rejectedSet[tc.Name]; !ok {
|
||||
rejectedSet[tc.Name] = struct{}{}
|
||||
rejected = append(rejected, tc.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
tc.Name = matchedName
|
||||
@@ -135,42 +162,23 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
rejected := make([]string, 0, len(rejectedSet))
|
||||
for name := range rejectedSet {
|
||||
rejected = append(rejected, name)
|
||||
}
|
||||
return out, rejected
|
||||
}
|
||||
|
||||
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||
if _, ok := allowed[name]; ok {
|
||||
return name
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
if canonical, ok := allowedCanonical[lower]; ok {
|
||||
return canonical
|
||||
}
|
||||
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
|
||||
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
|
||||
if loose == "" {
|
||||
return ""
|
||||
}
|
||||
for candidateLower, canonical := range allowedCanonical {
|
||||
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
|
||||
return canonical
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical)
|
||||
}
|
||||
|
||||
func parseToolCallsPayload(payload string) []ParsedToolCall {
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||
return nil
|
||||
// Try to repair backslashes first! Because LLMs often mix these two problems.
|
||||
repaired := repairInvalidJSONBackslashes(payload)
|
||||
// Try loose repair on top of that
|
||||
repaired = RepairLooseJSON(repaired)
|
||||
if err := json.Unmarshal([]byte(repaired), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
switch v := decoded.(type) {
|
||||
case map[string]any:
|
||||
@@ -186,6 +194,15 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeToolCallSyntax(text string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
return strings.Contains(lower, "tool_calls") ||
|
||||
strings.Contains(lower, "<tool_call") ||
|
||||
strings.Contains(lower, "<function_call") ||
|
||||
strings.Contains(lower, "<invoke") ||
|
||||
strings.Contains(lower, "function.name:")
|
||||
}
|
||||
|
||||
func parseToolCallList(v any) []ParsedToolCall {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
@@ -238,32 +255,3 @@ func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
|
||||
Input: parseToolCallInput(inputRaw),
|
||||
}, true
|
||||
}
|
||||
|
||||
func parseToolCallInput(v any) map[string]any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return x
|
||||
case string:
|
||||
raw := strings.TrimSpace(x)
|
||||
if raw == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
return map[string]any{"_raw": raw}
|
||||
default:
|
||||
b, err := json.Marshal(x)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
|
||||
235
internal/util/toolcalls_parse_markup.go
Normal file
235
internal/util/toolcalls_parse_markup.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var xmlToolCallPattern = regexp.MustCompile(`(?is)<tool_call>\s*(.*?)\s*</tool_call>`)
|
||||
var functionCallPattern = regexp.MustCompile(`(?is)<function_call>\s*([^<]+?)\s*</function_call>`)
|
||||
var functionParamPattern = regexp.MustCompile(`(?is)<function\s+parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</function\s+parameter>`)
|
||||
var antmlFunctionCallPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?function_call[^>]*(?:name|function)="([^"]+)"[^>]*>\s*(.*?)\s*</(?:[a-z0-9_]+:)?function_call>`)
|
||||
var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+name="([^"]+)"\s*>\s*(.*?)\s*</(?:[a-z0-9_]+:)?argument>`)
|
||||
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>`)
|
||||
|
||||
func parseXMLToolCalls(text string) []ParsedToolCall {
|
||||
matches := xmlToolCallPattern.FindAllString(text, -1)
|
||||
out := make([]ParsedToolCall, 0, len(matches)+1)
|
||||
for _, block := range matches {
|
||||
call, ok := parseSingleXMLToolCall(block)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, call)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
if call, ok := parseFunctionCallTagStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
if calls := parseAntmlFunctionCallStyles(text); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
if call, ok := parseInvokeFunctionCallStyle(text); ok {
|
||||
return []ParsedToolCall{call}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
|
||||
inner := strings.TrimSpace(block)
|
||||
inner = strings.TrimPrefix(inner, "<tool_call>")
|
||||
inner = strings.TrimSuffix(inner, "</tool_call>")
|
||||
inner = strings.TrimSpace(inner)
|
||||
if strings.HasPrefix(inner, "{") {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(inner), &payload); err == nil {
|
||||
name := strings.TrimSpace(asString(payload["tool"]))
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(asString(payload["tool_name"]))
|
||||
}
|
||||
if name != "" {
|
||||
input := map[string]any{}
|
||||
if params, ok := payload["params"].(map[string]any); ok {
|
||||
input = params
|
||||
} else if params, ok := payload["parameters"].(map[string]any); ok {
|
||||
input = params
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(strings.NewReader(block))
|
||||
name := ""
|
||||
params := map[string]any{}
|
||||
inParams := false
|
||||
inTool := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
tag := strings.ToLower(t.Name.Local)
|
||||
switch tag {
|
||||
case "tool":
|
||||
inTool = true
|
||||
for _, attr := range t.Attr {
|
||||
if strings.EqualFold(strings.TrimSpace(attr.Name.Local), "name") && strings.TrimSpace(name) == "" {
|
||||
name = strings.TrimSpace(attr.Value)
|
||||
}
|
||||
}
|
||||
case "parameters":
|
||||
inParams = true
|
||||
case "tool_name", "name":
|
||||
var v string
|
||||
if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" {
|
||||
name = strings.TrimSpace(v)
|
||||
}
|
||||
case "input", "arguments", "argument", "args", "params":
|
||||
var v string
|
||||
if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" {
|
||||
if parsed := parseToolCallInput(strings.TrimSpace(v)); len(parsed) > 0 {
|
||||
for k, vv := range parsed {
|
||||
params[k] = vv
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
if inParams || inTool {
|
||||
var v string
|
||||
if err := dec.DecodeElement(&v, &t); err == nil {
|
||||
params[t.Name.Local] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
case xml.EndElement:
|
||||
tag := strings.ToLower(t.Name.Local)
|
||||
if tag == "parameters" {
|
||||
inParams = false
|
||||
}
|
||||
if tag == "tool" {
|
||||
inTool = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
return ParsedToolCall{Name: strings.TrimSpace(name), Input: params}, true
|
||||
}
|
||||
|
||||
func parseFunctionCallTagStyle(text string) (ParsedToolCall, bool) {
|
||||
m := functionCallPattern.FindStringSubmatch(text)
|
||||
if len(m) < 2 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
input := map[string]any{}
|
||||
for _, pm := range functionParamPattern.FindAllStringSubmatch(text, -1) {
|
||||
if len(pm) < 3 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(pm[1])
|
||||
val := strings.TrimSpace(pm[2])
|
||||
if key != "" {
|
||||
input[key] = val
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseAntmlFunctionCallStyles(text string) []ParsedToolCall {
|
||||
matches := antmlFunctionCallPattern.FindAllStringSubmatch(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ParsedToolCall, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
if call, ok := parseSingleAntmlFunctionCallMatch(m); ok {
|
||||
out = append(out, call)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSingleAntmlFunctionCallMatch(m []string) (ParsedToolCall, bool) {
|
||||
if len(m) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
body := strings.TrimSpace(m[2])
|
||||
input := map[string]any{}
|
||||
if strings.HasPrefix(body, "{") {
|
||||
if err := json.Unmarshal([]byte(body), &input); err == nil {
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
}
|
||||
if pm := antmlParametersPattern.FindStringSubmatch(body); len(pm) >= 2 {
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(pm[1])), &input); err == nil {
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
}
|
||||
for _, am := range antmlArgumentPattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(am) < 3 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(am[1])
|
||||
v := strings.TrimSpace(am[2])
|
||||
if k != "" {
|
||||
input[k] = v
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) {
|
||||
m := invokeCallPattern.FindStringSubmatch(text)
|
||||
if len(m) < 3 {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
name := strings.TrimSpace(m[1])
|
||||
if name == "" {
|
||||
return ParsedToolCall{}, false
|
||||
}
|
||||
input := map[string]any{}
|
||||
for _, pm := range invokeParamPattern.FindAllStringSubmatch(m[2], -1) {
|
||||
if len(pm) < 3 {
|
||||
continue
|
||||
}
|
||||
k := strings.TrimSpace(pm[1])
|
||||
v := strings.TrimSpace(pm[2])
|
||||
if k != "" {
|
||||
input[k] = v
|
||||
}
|
||||
}
|
||||
if len(input) == 0 {
|
||||
if argsRaw := findMarkupTagValue(m[2], toolCallMarkupArgsTagNames, toolCallMarkupArgsPatternByTag); argsRaw != "" {
|
||||
input = parseMarkupInput(argsRaw)
|
||||
} else if kv := parseMarkupKVObject(m[2]); len(kv) > 0 {
|
||||
input = kv
|
||||
}
|
||||
}
|
||||
return ParsedToolCall{Name: name, Input: input}, true
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseToolCalls(t *testing.T) {
|
||||
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
|
||||
@@ -19,8 +22,8 @@ func TestParseToolCalls(t *testing.T) {
|
||||
func TestParseToolCallsFromFencedJSON(t *testing.T) {
|
||||
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +99,10 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) {
|
||||
func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
|
||||
mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 0 {
|
||||
t.Fatalf("expected standalone parser to ignore mixed prose, got %#v", calls)
|
||||
if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 1 {
|
||||
t.Fatalf("expected standalone parser to parse mixed prose payload, got %#v", calls)
|
||||
}
|
||||
|
||||
standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
@@ -109,10 +112,10 @@ func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) {
|
||||
func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
|
||||
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
|
||||
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 {
|
||||
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls)
|
||||
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
|
||||
t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,3 +140,380 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
|
||||
t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
|
||||
text := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command><description>show cwd</description></parameters></tool_call>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) {
|
||||
text := `<tool_call><tool_name>Bash</tool_name><parameters><command>pwd</command></parameters></tool_call>`
|
||||
res := ParseToolCallsDetailed(text, []string{"bash"})
|
||||
if !res.SawToolCallSyntax {
|
||||
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
|
||||
}
|
||||
if len(res.Calls) != 1 {
|
||||
t.Fatalf("expected one parsed call, got %#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
|
||||
text := `<tool_call>{"tool":"Bash","params":{"command":"pwd","description":"show cwd"}}</tool_call>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) {
|
||||
text := `<function_call>Bash</function_call><function parameter name="command">ls -la</function parameter><function parameter name="description">list</function parameter>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "ls -la" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) {
|
||||
text := `<antml:function_calls><antml:function_call name="Bash">{"command":"pwd","description":"x"}</antml:function_call></antml:function_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) {
|
||||
text := `<antml:function_calls><antml:function_call id="1" name="Bash"><antml:argument name="command">pwd</antml:argument><antml:argument name="description">x</antml:argument></antml:function_call></antml:function_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
|
||||
text := `<function_calls><invoke name="Bash"><parameter name="command">pwd</parameter><parameter name="description">d</parameter></invoke></function_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command 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"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testing.T) {
|
||||
text := `<antml:function_calls><antml:function_call id="x" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call></antml:function_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" {
|
||||
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "pwd" {
|
||||
t.Fatalf("expected command argument, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) {
|
||||
text := `<antml:function_calls><antml:function_call id="1" function="Bash"><antml:parameters>{"command":"pwd"}</antml:parameters></antml:function_call><antml:function_call id="2" function="Read"><antml:parameters>{"file_path":"README.md"}</antml:parameters></antml:function_call></antml:function_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash", "read"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %#v", calls)
|
||||
}
|
||||
if calls[0].Name != "bash" || calls[1].Name != "read" {
|
||||
t.Fatalf("expected canonical names [bash read], got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
|
||||
text := `<tool_call><name>read_file</function><arguments>{"path":"README.md"}</arguments></tool_call>`
|
||||
calls := ParseToolCalls(text, []string{"read_file"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected mismatched tags to be rejected, got %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairInvalidJSONBackslashes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\name"}`},
|
||||
{`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`},
|
||||
{`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`},
|
||||
{`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`},
|
||||
{`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`},
|
||||
{`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := repairInvalidJSONBackslashes(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("repairInvalidJSONBackslashes(%s) = %s; want %s", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLooseJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{`{tool_calls: [{"name": "search", "input": {"q": "go"}}]}`, `{"tool_calls": [{"name": "search", "input": {"q": "go"}}]}`},
|
||||
{`{name: "search", input: {q: "go"}}`, `{"name": "search", "input": {"q": "go"}}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := RepairLooseJSON(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("RepairLooseJSON(%s) = %s; want %s", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithUnquotedKeys(t *testing.T) {
|
||||
text := `这里是列表:{tool_calls: [{"name": "todowrite", "input": {"todos": "test"}}]}`
|
||||
availableTools := []string{"todowrite"}
|
||||
|
||||
parsed := ParseToolCalls(text, availableTools)
|
||||
if len(parsed) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(parsed))
|
||||
}
|
||||
if parsed[0].Name != "todowrite" {
|
||||
t.Errorf("expected tool todowrite, got %s", parsed[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithInvalidBackslashes(t *testing.T) {
|
||||
// DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings
|
||||
// Note: using raw string to simulate what AI actually sends in the stream
|
||||
text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}`
|
||||
availableTools := []string{"execute_command"}
|
||||
|
||||
parsed := ParseToolCalls(text, availableTools)
|
||||
// If standard JSON fails, buildToolCallCandidates should still extract the object,
|
||||
// and parseToolCallsPayload should repair it.
|
||||
if len(parsed) != 1 {
|
||||
// If it still fails, let's see why
|
||||
candidates := buildToolCallCandidates(text)
|
||||
t.Logf("Candidates: %v", candidates)
|
||||
t.Fatalf("expected 1 tool call, got %d", len(parsed))
|
||||
}
|
||||
|
||||
cmd, ok := parsed[0].Input["command"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected command string in input, got %v", parsed[0].Input)
|
||||
}
|
||||
|
||||
expected := "cd D:\\git_codes && dir"
|
||||
if cmd != expected {
|
||||
t.Errorf("expected command %q, got %q", expected, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithDeepSeekHallucination(t *testing.T) {
|
||||
// 模拟 DeepSeek 典型的幻觉输出:未加引号的键名 + 包含 Windows 路径的嵌套 JSON 字符串 + 漏掉列表的方括号
|
||||
text := `检测到实施意图——实现经典算法。需在misc/目录创建Python文件。
|
||||
关键约束:
|
||||
1. Windows UTF-8编码处理
|
||||
2. 必须用绝对路径导入
|
||||
3. 禁止write覆盖已有文件(misc/目录允许创建新文件)
|
||||
将任务分解并委托:
|
||||
- 研究8皇后算法模式(并行探索)
|
||||
- 实现带可视化输出的解决方案(unspecified-high)
|
||||
先创建todo列表追踪步骤。
|
||||
{tool_calls: [{"name": "todowrite", "input": {"todos": {"content": "研究8皇后问题算法模式(回溯法)和输出格式", "status": "pending", "priority": "high"}, {"content": "在misc/目录创建8皇后Python脚本,包含完整解决方案和可视化输出", "status": "pending", "priority": "high"}, {"content": "验证脚本正确性(运行测试)", "status": "pending", "priority": "medium"}}}]}`
|
||||
|
||||
availableTools := []string{"todowrite"}
|
||||
parsed := ParseToolCalls(text, availableTools)
|
||||
|
||||
if len(parsed) != 1 {
|
||||
cands := buildToolCallCandidates(text)
|
||||
for i, c := range cands {
|
||||
t.Logf("CAND %d: %s", i, c)
|
||||
repaired := RepairLooseJSON(c)
|
||||
t.Logf(" REPAIRED: %s", repaired)
|
||||
}
|
||||
t.Fatalf("expected 1 tool call, got %d. Candidates: %v", len(parsed), buildToolCallCandidates(text))
|
||||
}
|
||||
|
||||
if parsed[0].Name != "todowrite" {
|
||||
t.Errorf("expected tool name 'todowrite', got %q", parsed[0].Name)
|
||||
}
|
||||
|
||||
todos, ok := parsed[0].Input["todos"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected 'todos' to be parsed as a list, got %T: %#v", parsed[0].Input["todos"], parsed[0].Input["todos"])
|
||||
}
|
||||
if len(todos) != 3 {
|
||||
t.Errorf("expected 3 todo items, got %d", len(todos))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithMixedWindowsPaths(t *testing.T) {
|
||||
// 更复杂的案例:嵌套 JSON 字符串中的反斜杠未转义
|
||||
text := `关键约束: 1. Windows UTF-8编码处理 2. 必须用绝对路径导入 D:\git_codes\ds2api\misc
|
||||
{tool_calls: [{"name": "write_file", "input": "{\"path\": \"D:\\git_codes\\ds2api\\misc\\queens.py\", \"content\": \"print('hello')\"}"}]}`
|
||||
|
||||
availableTools := []string{"write_file"}
|
||||
parsed := ParseToolCalls(text, availableTools)
|
||||
|
||||
if len(parsed) != 1 {
|
||||
t.Fatalf("expected 1 tool call from mixed text with paths, got %d", len(parsed))
|
||||
}
|
||||
|
||||
path, _ := parsed[0].Input["path"].(string)
|
||||
// 在解析后的 Go map 中,反斜杠应该被还原
|
||||
if !strings.Contains(path, "D:\\git_codes") && !strings.Contains(path, "D:/git_codes") {
|
||||
t.Errorf("expected path to contain Windows style separators, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallInputRepairsControlCharsInPath(t *testing.T) {
|
||||
in := `{"path":"D:\tmp\new\readme.txt","content":"line1\nline2"}`
|
||||
parsed := parseToolCallInput(in)
|
||||
|
||||
path, ok := parsed["path"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected path string in parsed input, got %#v", parsed["path"])
|
||||
}
|
||||
if path != `D:\tmp\new\readme.txt` {
|
||||
t.Fatalf("expected repaired windows path, got %q", path)
|
||||
}
|
||||
|
||||
content, ok := parsed["content"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected content string in parsed input, got %#v", parsed["content"])
|
||||
}
|
||||
if content != "line1\nline2" {
|
||||
t.Fatalf("expected non-path field to keep decoded escapes, got %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLooseJSONWithNestedObjects(t *testing.T) {
|
||||
// 测试嵌套对象的修复:DeepSeek 幻觉输出,每个元素内部包含嵌套 {}
|
||||
// 注意:正则只支持单层嵌套,不支持更深层次的嵌套
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// 1. 单层嵌套对象(核心修复目标)
|
||||
{
|
||||
name: "单层嵌套 - 2个元素",
|
||||
input: `"todos": {"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}`,
|
||||
expected: `"todos": [{"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}]`,
|
||||
},
|
||||
// 2. 3个单层嵌套对象
|
||||
{
|
||||
name: "3个单层嵌套对象",
|
||||
input: `"items": {"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}`,
|
||||
expected: `"items": [{"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}]`,
|
||||
},
|
||||
// 3. 混合嵌套:有些字段是对象,有些是原始值
|
||||
{
|
||||
name: "混合嵌套 - 对象和原始值混合",
|
||||
input: `"items": {"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}`,
|
||||
expected: `"items": [{"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}]`,
|
||||
},
|
||||
// 4. 4个嵌套对象(边界测试)
|
||||
{
|
||||
name: "4个嵌套对象",
|
||||
input: `"todos": {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}`,
|
||||
expected: `"todos": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]`,
|
||||
},
|
||||
// 5. DeepSeek 典型幻觉:无空格逗号分隔
|
||||
{
|
||||
name: "无空格逗号分隔",
|
||||
input: `"results": {"name": "a"}, {"name": "b"}, {"name": "c"}`,
|
||||
expected: `"results": [{"name": "a"}, {"name": "b"}, {"name": "c"}]`,
|
||||
},
|
||||
// 6. 嵌套数组(数组在对象内,不是深层嵌套)
|
||||
{
|
||||
name: "对象内包含数组",
|
||||
input: `"data": {"items": [1,2,3]}, {"items": [4,5,6]}`,
|
||||
expected: `"data": [{"items": [1,2,3]}, {"items": [4,5,6]}]`,
|
||||
},
|
||||
// 7. 真实的 DeepSeek 8皇后问题输出
|
||||
{
|
||||
name: "DeepSeek 8皇后真实输出",
|
||||
input: `"todos": {"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}`,
|
||||
expected: `"todos": [{"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}]`,
|
||||
},
|
||||
// 8. 简单无嵌套对象(回归测试)
|
||||
{
|
||||
name: "简单无嵌套对象",
|
||||
input: `"items": {"a": 1}, {"b": 2}`,
|
||||
expected: `"items": [{"a": 1}, {"b": 2}]`,
|
||||
},
|
||||
// 9. 更复杂的单层嵌套
|
||||
{
|
||||
name: "复杂单层嵌套",
|
||||
input: `"functions": {"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}`,
|
||||
expected: `"functions": [{"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}]`,
|
||||
},
|
||||
// 10. 5个嵌套对象
|
||||
{
|
||||
name: "5个嵌套对象",
|
||||
input: `"tasks": {"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}`,
|
||||
expected: `"tasks": [{"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := RepairLooseJSON(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("[%s] RepairLooseJSON with nested objects:\n input: %s\n got: %s\n expected: %s", tt.name, tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
internal/util/toolcalls_textkv.go
Normal file
55
internal/util/toolcalls_textkv.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var textKVNamePattern = regexp.MustCompile(`(?is)function\.name:\s*([a-zA-Z0-9_\-.]+)`)
|
||||
|
||||
func parseTextKVToolCalls(text string) []ParsedToolCall {
|
||||
var out []ParsedToolCall
|
||||
matches := textKVNamePattern.FindAllStringSubmatchIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, match := range matches {
|
||||
name := text[match[2]:match[3]]
|
||||
|
||||
offset := match[1]
|
||||
endSearch := len(text)
|
||||
if i+1 < len(matches) {
|
||||
endSearch = matches[i+1][0]
|
||||
}
|
||||
|
||||
searchArea := text[offset:endSearch]
|
||||
argIdx := strings.Index(searchArea, "function.arguments:")
|
||||
if argIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
startIdx := offset + argIdx + len("function.arguments:")
|
||||
braceIdx := strings.IndexByte(text[startIdx:endSearch], '{')
|
||||
if braceIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
actualStart := startIdx + braceIdx
|
||||
objJson, _, ok := extractJSONObject(text, actualStart)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
input := parseToolCallInput(objJson)
|
||||
out = append(out, ParsedToolCall{
|
||||
Name: name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
63
internal/util/toolcalls_textkv_test.go
Normal file
63
internal/util/toolcalls_textkv_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTextKVToolCalls_Basic(t *testing.T) {
|
||||
text := `
|
||||
[TOOL_CALL_HISTORY]
|
||||
status: already_called
|
||||
origin: assistant
|
||||
not_user_input: true
|
||||
tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b
|
||||
function.name: execute_command
|
||||
function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}
|
||||
[/TOOL_CALL_HISTORY]
|
||||
|
||||
Some other text thinking...
|
||||
`
|
||||
calls := ParseToolCalls(text, []string{"execute_command"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "execute_command" {
|
||||
t.Fatalf("unexpected name: %s", calls[0].Name)
|
||||
}
|
||||
if calls[0].Input["command"] != "cd scripts && python check_syntax.py example.py" {
|
||||
t.Fatalf("unexpected command arg: %v", calls[0].Input["command"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextKVToolCalls_Multiple(t *testing.T) {
|
||||
text := `
|
||||
function.name: read_file
|
||||
function.arguments: {
|
||||
"path": "abc.txt"
|
||||
}
|
||||
|
||||
function.name: bash
|
||||
function.arguments: {"command": "ls"}
|
||||
`
|
||||
calls := ParseToolCalls(text, []string{"read_file", "bash"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "read_file" {
|
||||
t.Fatalf("unexpected 1st name: %s", calls[0].Name)
|
||||
}
|
||||
if calls[1].Name != "bash" {
|
||||
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTextKVToolCalls_Standalone(t *testing.T) {
|
||||
text := "function.name: read_file\nfunction.arguments: {\"path\":\"README.md\"}"
|
||||
calls := ParseStandaloneToolCalls(text, []string{"read_file"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "read_file" {
|
||||
t.Fatalf("unexpected name: %s", calls[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -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) != 0 {
|
||||
t.Fatalf("expected fenced code block ignored, got %d calls", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
185
internal/version/version.go
Normal file
185
internal/version/version.go
Normal 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
|
||||
}
|
||||
39
internal/version/version_test.go
Normal file
39
internal/version/version_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# DS2API Refactor Baseline (Historical Snapshot)
|
||||
|
||||
- Snapshot time: `2026-02-22T08:53:54Z`
|
||||
- Snapshot branch: `dev`
|
||||
- Snapshot HEAD: `5d3989a`
|
||||
- Scope: backend + node api + webui large-file decoupling (no behavior change)
|
||||
|
||||
## Gate Commands
|
||||
|
||||
1. `./tests/scripts/run-unit-all.sh`
|
||||
- Result: PASS
|
||||
- Includes:
|
||||
- `go test ./...`
|
||||
- `node --test api/helpers/stream-tool-sieve.test.js api/chat-stream.test.js api/compat/js_compat_test.js`
|
||||
2. `npm --prefix webui run build`
|
||||
- Result: PASS
|
||||
3. `./tests/scripts/check-refactor-line-gate.sh`
|
||||
- Result: PASS (`checked=131 missing=0 over_limit=0`)
|
||||
4. Stage gates (1-5) replay:
|
||||
- `go test ./internal/config ./internal/admin ./internal/account ./internal/deepseek ./internal/format/openai` -> PASS
|
||||
- `go test ./internal/adapter/openai ./internal/util ./internal/sse ./internal/compat` -> PASS
|
||||
- `go test ./internal/adapter/claude ./internal/adapter/gemini ./internal/config` -> PASS
|
||||
- `go test ./internal/testsuite ./cmd/ds2api-tests` -> PASS
|
||||
- `node --test api/helpers/stream-tool-sieve.test.js api/chat-stream.test.js api/compat/js_compat_test.js` -> PASS
|
||||
5. Final full regression:
|
||||
- `go test ./... -count=1` -> PASS
|
||||
|
||||
## Notes
|
||||
|
||||
- This file records a historical baseline for refactor process tracking.
|
||||
- It is not intended to represent the current repository HEAD.
|
||||
- Frontend manual smoke for phase 6 still requires human execution and sign-off.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Line gate targets for large-file decoupling refactor.
|
||||
# Default limit: 300 lines
|
||||
# Backend default limit: 300 lines
|
||||
# Frontend (webui/) default limit: 500 lines
|
||||
# Entry/facade limit: 120 lines (enforced in script)
|
||||
# Test files are ignored by the gate script.
|
||||
|
||||
internal/config/config.go
|
||||
internal/config/logger.go
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Refactor Line Gate
|
||||
|
||||
## Rules
|
||||
|
||||
1. Production file default upper bound: `<= 300` lines.
|
||||
2. Entry/facade files upper bound: `<= 120` lines.
|
||||
3. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
|
||||
4. Test files are out of scope for this gate.
|
||||
|
||||
## Command
|
||||
|
||||
```bash
|
||||
./tests/scripts/check-refactor-line-gate.sh
|
||||
```
|
||||
|
||||
## Naming Note
|
||||
|
||||
- Original split plan used `internal/admin/handler_accounts_test.go` for account probing logic.
|
||||
- In Go, `*_test.go` files are test-only compilation units and cannot host production handlers.
|
||||
- The production file is implemented as `internal/admin/handler_accounts_testing.go`.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
{
|
||||
"calls": []
|
||||
}
|
||||
"calls": [],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": true,
|
||||
"rejectedToolNames": [
|
||||
"unknown_tool"
|
||||
]
|
||||
}
|
||||
@@ -6,5 +6,8 @@
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
{
|
||||
"calls": []
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
|
||||
13
tests/compat/expected/toolcalls_function_call_tag.json
Normal file
13
tests/compat/expected/toolcalls_function_call_tag.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
13
tests/compat/expected/toolcalls_invoke_attr.json
Normal file
13
tests/compat/expected/toolcalls_invoke_attr.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
13
tests/compat/expected/toolcalls_loose_normalize.json
Normal file
13
tests/compat/expected/toolcalls_loose_normalize.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
{
|
||||
"calls": []
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
{
|
||||
"calls": []
|
||||
"calls": [
|
||||
{
|
||||
"name": "read_file",
|
||||
"input": {
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"path": "README.MD"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sawToolCallSyntax": true,
|
||||
"rejectedByPolicy": false,
|
||||
"rejectedToolNames": []
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user