Compare commits

...

42 Commits

Author SHA1 Message Date
CJACK.
d50d39e2e5 Merge pull request #169 from CJackHwang/dev
Merge pull request #168 from CJackHwang/codex/fix-vercel-deployment-issue-with-api-calls

fix(js): avoid false tool-call capture on plain tool_calls prose
2026-03-30 16:08:39 +08:00
CJACK.
01393837be Merge pull request #168 from CJackHwang/codex/fix-vercel-deployment-issue-with-api-calls
fix(js): avoid false tool-call capture on plain tool_calls prose
2026-03-30 16:01:51 +08:00
CJACK.
1fe1240240 fix(js): prevent XML wrapper attribute tool_calls scan loop 2026-03-30 15:59:34 +08:00
CJACK.
c07736fbea chore: set shared tool-sieve context tail window to 2048 2026-03-30 15:41:38 +08:00
CJACK.
775bf3b578 refactor(js): align tool-sieve segment start and tail window with go 2026-03-30 15:41:26 +08:00
CJACK.
ab3943ebeb test(js): cover numbered planning prose around tool calls 2026-03-30 15:39:09 +08:00
CJACK.
6efba7b2e4 fix(js): avoid false tool-call capture on plain tool_calls prose 2026-03-30 12:51:33 +08:00
CJACK.
765d0231cd Merge pull request #166 from CJackHwang/dev
chore: relocate sha3 WASM asset to internal directory and update build configurations
2026-03-30 12:23:46 +08:00
CJACK.
aebf3e9119 Merge pull request #167 from CJackHwang/codex/remove-dangling-agent-xml-tags
Fix dangling agent XML cleanup and XML-escape tool-call prompt serialization
2026-03-30 12:22:58 +08:00
CJACK.
535d9298a7 Scope dangling result-tag cleanup to leaked wrapper fragments 2026-03-30 12:22:04 +08:00
CJACK.
b790545d82 Fix dangling agent XML cleanup and escape tool call prompt XML 2026-03-30 11:23:16 +08:00
CJACK
c95bf7b667 chore: relocate sha3 WASM asset to internal directory and update build configurations 2026-03-30 02:23:45 +08:00
CJACK
d79565b250 docs: move documentation files to a dedicated directory and update references 2026-03-30 02:07:24 +08:00
CJACK
dc39de062b refactor: update wasm asset path in vercel configuration and remove obsolete binary file 2026-03-30 02:03:08 +08:00
CJACK
a7c9dfd7c0 refactor: remove configurable toolcall policy and fix to feature matching with high-confidence early emit 2026-03-30 01:56:25 +08:00
CJACK
822b14ed6b feat: add configurable token_refresh_interval_hours to runtime settings with validation and hot-reload support 2026-03-30 01:41:13 +08:00
CJACK
af7c7c6770 refactor: rename sanitizeLeakedToolHistory to sanitizeLeakedOutput for improved clarity 2026-03-30 01:06:22 +08:00
CJACK
868a60b70b chore: bump version from 2.4.1 to 2.5.1 2026-03-30 00:29:17 +08:00
CJACK
30a53b6c43 refactor: remove legacy TOOL_CALL_HISTORY/TOOL_RESULT_HISTORY markers and consolidate tool call formatting into a new prompt package 2026-03-30 00:20:38 +08:00
CJACK.
034c00f10e Merge pull request #163 from CJackHwang/dev
docs: update API documentation, deployment guides, and README with new admin endpoints, compatibility notes, and build instructions
2026-03-29 19:50:40 +08:00
CJACK
c3c644ff8c 111 2026-03-29 19:49:52 +08:00
CJACK
621599f8ad test: update message preparation tests to expect explicit User role markers 2026-03-29 19:41:03 +08:00
CJACK
aeb519c211 docs: update API documentation, deployment guides, and README with new admin endpoints, compatibility notes, and build instructions 2026-03-29 19:17:07 +08:00
CJACK
075728cca6 feat: add support for intercepting and sanitizing agent-style XML tags to prevent output leaks 2026-03-29 17:15:14 +08:00
CJACK
883607ac87 refactor: update prompt formatting to use system instruction tags and explicit user markers for improved model reasoning 2026-03-29 16:40:44 +08:00
CJACK
1d6a8e7008 refactor: centralize tool-calling instructions into a shared utility and update Claude/OpenAI adapters to use the unified format. 2026-03-29 16:05:35 +08:00
CJACK
f041ebab93 refactor: optimize tool-calling prompt instructions and examples for improved model adherence 2026-03-29 15:18:43 +08:00
CJACK
3ab9d44f60 feat: suppress output of partial XML tool tag fragments in stream processing 2026-03-29 14:59:30 +08:00
CJACK
4b42fe9086 fix: prevent XML tool call leakage by strictly matching opening and closing tag pairs during streaming 2026-03-29 14:40:47 +08:00
CJACK
302bcefeb5 feat: implement XML-based tool call extraction and refactor sieve utilities into dedicated modules 2026-03-29 13:01:11 +08:00
CJACK.
19b4f879c5 Merge pull request #161 from CJackHwang/codex/update-ds2api-project-documentation
Prefer XML canonical format for tool calls; prioritize XML/Markup parsing and update docs/tests
2026-03-29 11:22:51 +08:00
CJACK.
56a3ed19e8 fix(toolcall): support canonical xml params and guard json shadowing 2026-03-29 11:15:52 +08:00
CJACK.
958f4e39b5 feat(toolcall): prioritize XML for model output and parsing 2026-03-29 10:53:38 +08:00
CJACK.
6e8f3185d5 Merge pull request #157 from CJackHwang/codex/analyze-toolcall-output-formatting-issue
Sanitize leaked tool-call wire format in assistant text
2026-03-22 22:46:07 +08:00
CJACK.
0925e83b9b Stop embedding tool-call envelopes into prompt content 2026-03-22 22:36:15 +08:00
CJACK.
87c231e736 Sanitize leaked tool-call wire format in assistant text 2026-03-22 22:17:40 +08:00
CJACK.
390f7580e5 Merge pull request #156 from CJackHwang/dev
Merge pull request #153 from CJackHwang/codex/investigate-tool-execution-bugs-in-output-7ocr8f

Relax tool-name allow-listing and improve tool-call detection/parsing across adapters and sieve
2026-03-22 21:40:03 +08:00
CJACK.
5887821a9d Merge pull request #153 from CJackHwang/codex/investigate-tool-execution-bugs-in-output-7ocr8f
Relax tool-name allow-listing and improve tool-call detection/parsing across adapters and sieve
2026-03-22 21:26:55 +08:00
CJACK.
7794006513 Update VERSION 2026-03-22 21:26:34 +08:00
CJACK.
47d4499d47 Merge pull request #155 from CJackHwang/codex/review-and-fix-pr-#153-issues
Sync tool-call compat fixtures and update node test to match permissive tool-call policy
2026-03-22 21:25:18 +08:00
CJACK.
15891ddc25 Fix quality-gate fixture drift for permissive tool-call policy 2026-03-22 21:24:06 +08:00
CJACK.
97a81c4191 Harden toolcall leak interception for function-style payloads 2026-03-22 20:07:12 +08:00
98 changed files with 2549 additions and 1018 deletions

View File

@@ -10,6 +10,8 @@ DS2API_ADMIN_KEY=change-me
DS2API_CONFIG_PATH=/app/config.json
# 2) inline JSON or Base64 JSON
# DS2API_CONFIG_JSON=
# 3) legacy compatibility alias
# CONFIG_JSON=
# Optional: static admin assets path
# DS2API_STATIC_ADMIN_DIR=/app/static/admin

View File

@@ -79,7 +79,7 @@ jobs:
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
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 config.example.json .env.example internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/"
cp -R static/admin "${STAGE}/static/admin"
if [ "${GOOS}" = "windows" ]; then

View File

@@ -46,6 +46,7 @@ Use it per deployment mode:
- Local run: read `config.json` directly
- Docker / Vercel: generate Base64 from `config.json`, then set `DS2API_CONFIG_JSON`
- Compatibility note: `DS2API_CONFIG_JSON` may also contain raw JSON directly; `CONFIG_JSON` is the legacy fallback variable
```bash
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
@@ -65,6 +66,7 @@ Two header formats accepted:
| --- | --- |
| Bearer Token | `Authorization: Bearer <token>` |
| API Key Header | `x-api-key: <token>` (no `Bearer` prefix) |
| Gemini-compatible | `x-goog-api-key: <token>` or `?key=<token>` / `?api_key=<token>` |
**Auth behavior**:
@@ -72,6 +74,7 @@ Two header formats accepted:
- Token is not in `config.keys`**Direct token mode**: treated as a DeepSeek token directly
**Optional header**: `X-Ds2-Target-Account: <email_or_mobile>` — Pin a specific managed account.
Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=` as the caller credential source.
### Admin Endpoints (`/admin/*`)
@@ -124,13 +127,16 @@ Two header formats accepted:
| GET | `/admin/queue/status` | Admin | Account queue status |
| POST | `/admin/accounts/test` | Admin | Test one account |
| POST | `/admin/accounts/test-all` | Admin | Test all accounts |
| POST | `/admin/accounts/sessions/delete-all` | Admin | Delete all sessions for one account |
| POST | `/admin/import` | Admin | Batch import keys/accounts |
| POST | `/admin/test` | Admin | Test API through service |
| POST | `/admin/vercel/sync` | Admin | Sync config to Vercel |
| GET | `/admin/vercel/status` | Admin | Vercel sync status |
| POST | `/admin/vercel/status` | Admin | Vercel sync status / draft compare |
| GET | `/admin/export` | Admin | Export config JSON/Base64 |
| GET | `/admin/dev/captures` | Admin | Read local packet-capture entries |
| DELETE | `/admin/dev/captures` | Admin | Clear local packet-capture entries |
| GET | `/admin/version` | Admin | Check current version and latest Release |
---
@@ -580,6 +586,7 @@ Returns sanitized config.
```json
{
"keys": ["k1", "k2"],
"env_backed": false,
"accounts": [
{
"identifier": "user@example.com",
@@ -599,7 +606,7 @@ Returns sanitized config.
### `POST /admin/config`
Updatable fields: `keys`, `accounts`, `claude_mapping`.
Only updates `keys`, `accounts`, and `claude_mapping`.
**Request**:
@@ -620,25 +627,27 @@ Updatable fields: `keys`, `accounts`, `claude_mapping`.
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`
- `success`
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
- `responses` / `embeddings`
- `auto_delete` (`sessions`)
- `claude_mapping` / `model_aliases`
- `env_backed`, `needs_vercel_sync`
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
### `PUT /admin/settings`
Hot-updates runtime settings. Supported fields:
- `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight`
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`
- `toolcall` policy is fixed and is no longer writable through settings
### `POST /admin/settings/password`
@@ -650,6 +659,8 @@ Request example:
{"new_password":"your-new-password"}
```
It also accepts `{"password":"your-new-password"}`.
### `POST /admin/config/import`
Imports full config with:
@@ -658,6 +669,8 @@ Imports full config with:
- `mode=replace`
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
Query params `?mode=merge` / `?mode=replace` are also supported.
Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored.
### `GET /admin/config/export`
@@ -683,6 +696,7 @@ Exports full config in three forms: `config`, `json`, and `base64`.
| --- | --- | --- |
| `page` | `1` | ≥ 1 |
| `page_size` | `10` | 1100 |
| `q` | empty | Filter by identifier / email / mobile |
**Response**:
@@ -695,7 +709,8 @@ Exports full config in three forms: `config`, `json`, and `base64`.
"mobile": "",
"has_password": true,
"has_token": true,
"token_preview": "abc..."
"token_preview": "abc...",
"test_status": "ok"
}
],
"total": 25,
@@ -705,6 +720,8 @@ Exports full config in three forms: `config`, `json`, and `base64`.
}
```
Returned items also include `test_status`, usually `ok` or `failed`.
### `POST /admin/accounts`
```json
@@ -757,10 +774,14 @@ Exports full config in three forms: `config`, `json`, and `base64`.
"success": true,
"response_time": 1240,
"message": "API test successful (session creation only)",
"model": "deepseek-chat"
"model": "deepseek-chat",
"session_count": 0,
"config_writable": true
}
```
If a `message` is provided, `thinking` may also be included when the upstream response carries reasoning text.
### `POST /admin/accounts/test-all`
Optional request field: `model`.
@@ -774,6 +795,25 @@ Optional request field: `model`.
}
```
The internal concurrency limit is currently fixed at 5.
### `POST /admin/accounts/sessions/delete-all`
Deletes all DeepSeek sessions for a specific account. Request example:
```json
{"identifier":"user@example.com"}
```
Response:
```json
{"success": true, "message": "删除成功"}
```
If the account is missing or deletion fails, `success` becomes `false` and `message` contains the error.
The current handler returns the Chinese literal `删除成功` on success.
### `POST /admin/import`
Batch import keys and accounts.
@@ -851,16 +891,25 @@ Or manual deploy required:
}
```
Failed account checks are returned in `failed_accounts`, and any saved Vercel credentials are returned in `saved_credentials`.
### `GET /admin/vercel/status`
```json
{
"synced": true,
"last_sync_time": 1738400000,
"has_synced_before": true
"has_synced_before": true,
"env_backed": false,
"config_hash": "....",
"last_synced_hash": "....",
"draft_hash": "....",
"draft_differs": false
}
```
`POST /admin/vercel/status` can also accept `config_override` to compare a draft config against the current synced config.
### `GET /admin/export`
```json
@@ -870,6 +919,29 @@ Or manual deploy required:
}
```
This is the same payload as `GET /admin/config/export`, just with a shorter path.
### `GET /admin/version`
Checks the current build version and the latest GitHub Release:
```json
{
"success": true,
"current_version": "2.3.5",
"current_tag": "v2.3.5",
"source": "file:VERSION",
"checked_at": "2026-03-29T00:00:00Z",
"latest_tag": "v2.3.6",
"latest_version": "2.3.6",
"release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6",
"published_at": "2026-03-28T12:00:00Z",
"has_update": true
}
```
If GitHub API access fails, the response includes `check_error` while still returning HTTP 200.
### `GET /admin/dev/captures`
Reads local packet-capture status and recent entries (Admin auth required):

95
API.md
View File

@@ -46,6 +46,7 @@ cp config.example.json config.json
- 本地运行:直接读取 `config.json`
- Docker / Vercel`config.json` 生成 Base64填入 `DS2API_CONFIG_JSON`
- 兼容写法:`DS2API_CONFIG_JSON` 也可直接填原始 JSON`CONFIG_JSON` 是旧版兼容回退变量
```bash
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
@@ -65,6 +66,7 @@ Vercel 一键部署可先只填 `DS2API_ADMIN_KEY`,部署后在 `/admin` 导
| --- | --- |
| Bearer Token | `Authorization: Bearer <token>` |
| API Key Header | `x-api-key: <token>`(无 `Bearer` 前缀) |
| Gemini 兼容 | `x-goog-api-key: <token>``?key=<token>` / `?api_key=<token>` |
**鉴权行为**
@@ -72,6 +74,7 @@ Vercel 一键部署可先只填 `DS2API_ADMIN_KEY`,部署后在 `/admin` 导
- token 不在 `config.keys` 中 → **直通 token 模式**,直接作为 DeepSeek token 使用
**可选请求头**`X-Ds2-Target-Account: <email_or_mobile>` — 指定使用某个托管账号。
Gemini 兼容客户端还可以使用 `x-goog-api-key``?key=``?api_key=` 作为凭据来源。
### Admin 接口(`/admin/*`
@@ -124,13 +127,16 @@ Vercel 一键部署可先只填 `DS2API_ADMIN_KEY`,部署后在 `/admin` 导
| GET | `/admin/queue/status` | Admin | 账号队列状态 |
| POST | `/admin/accounts/test` | Admin | 测试单个账号 |
| POST | `/admin/accounts/test-all` | Admin | 测试全部账号 |
| POST | `/admin/accounts/sessions/delete-all` | Admin | 删除某账号的全部会话 |
| POST | `/admin/import` | Admin | 批量导入 keys/accounts |
| POST | `/admin/test` | Admin | 测试当前 API 可用性 |
| POST | `/admin/vercel/sync` | Admin | 同步配置到 Vercel |
| GET | `/admin/vercel/status` | Admin | Vercel 同步状态 |
| POST | `/admin/vercel/status` | Admin | Vercel 同步状态 / 草稿对比 |
| GET | `/admin/export` | Admin | 导出配置 JSON/Base64 |
| GET | `/admin/dev/captures` | Admin | 查看本地抓包记录 |
| DELETE | `/admin/dev/captures` | Admin | 清空本地抓包记录 |
| GET | `/admin/version` | Admin | 查询当前版本与最新 Release |
---
@@ -286,7 +292,8 @@ data: [DONE]
补充说明:
- **非代码块上下文**下,工具 JSON 即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- 解析器以 XML/Markup 为最高优先级,并兼容 JSON、ANTML、text-kv 等格式输入;最终按客户端协议转译为对应 tool call 结构OpenAI/Claude/Gemini
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
---
@@ -346,7 +353,8 @@ data: [DONE]
```
流式场景下若 `tool_choice=required` 违规,会返回 `response.failed` 后结束(不再发送 `response.completed`)。
未在 `tools` 声明中的工具名会被严格拒绝,不会作为有效 tool call 下发。
> 当前版本说明:解析层默认“尽量提取结构化 tool call”未启用基于 `tools` allow-list 的硬拒绝;是否执行仍应由你的工具执行器做白名单校验。
### `GET /v1/responses/{response_id}`
@@ -492,6 +500,8 @@ data: {"type":"message_stop"}
}
```
返回项还会包含 `test_status`,当前值通常为 `ok``failed`
---
## Gemini 兼容接口
@@ -585,6 +595,7 @@ data: {"type":"message_stop"}
```json
{
"keys": ["k1", "k2"],
"env_backed": false,
"accounts": [
{
"identifier": "user@example.com",
@@ -604,7 +615,7 @@ data: {"type":"message_stop"}
### `POST /admin/config`
更新 `keys``accounts``claude_mapping`
更新 `keys``accounts``claude_mapping`
**请求**
@@ -625,25 +636,27 @@ data: {"type":"message_stop"}
读取运行时设置与状态,返回:
- `admin`JWT 过期、默认密码告警等)
- `runtime``account_max_inflight``account_max_queue``global_max_inflight`
- `toolcall` / `responses` / `embeddings`
- `success`
- `admin``has_password_hash``jwt_expire_hours``jwt_valid_after_unix``default_password_warning`
- `runtime``account_max_inflight``account_max_queue``global_max_inflight``token_refresh_interval_hours`
- `responses` / `embeddings`
- `auto_delete``sessions`
- `claude_mapping` / `model_aliases`
- `env_backed``needs_vercel_sync`
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
### `PUT /admin/settings`
热更新运行时设置。支持更新:
- `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight`
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.sessions`
- `claude_mapping`
- `model_aliases`
- `toolcall` 策略已固定,不再作为可写入字段
### `POST /admin/settings/password`
@@ -655,6 +668,8 @@ data: {"type":"message_stop"}
{"new_password":"your-new-password"}
```
也兼容 `{"password":"your-new-password"}`
### `POST /admin/config/import`
导入完整配置,支持:
@@ -663,6 +678,8 @@ data: {"type":"message_stop"}
- `mode=replace`
请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。
也支持在查询参数里传 `?mode=merge` / `?mode=replace`
导入时会接受 `keys``accounts``claude_mapping` / `claude_model_mapping``model_aliases``admin``runtime``responses``embeddings``auto_delete` 等字段;`toolcall` 相关字段会被忽略。
### `GET /admin/config/export`
@@ -688,6 +705,7 @@ data: {"type":"message_stop"}
| --- | --- | --- |
| `page` | `1` | ≥ 1 |
| `page_size` | `10` | 1100 |
| `q` | 空 | 按 identifier / email / mobile 过滤 |
**响应**
@@ -700,7 +718,8 @@ data: {"type":"message_stop"}
"mobile": "",
"has_password": true,
"has_token": true,
"token_preview": "abc..."
"token_preview": "abc...",
"test_status": "ok"
}
],
"total": 25,
@@ -762,10 +781,14 @@ data: {"type":"message_stop"}
"success": true,
"response_time": 1240,
"message": "API 测试成功(仅会话创建)",
"model": "deepseek-chat"
"model": "deepseek-chat",
"session_count": 0,
"config_writable": true
}
```
如果传入 `message`,还会附带 `thinking`(当上游返回思考内容时)。
### `POST /admin/accounts/test-all`
可选请求字段:`model`
@@ -779,6 +802,24 @@ data: {"type":"message_stop"}
}
```
内部并发上限当前固定为 5。
### `POST /admin/accounts/sessions/delete-all`
清空指定账号的所有 DeepSeek 会话。请求体示例:
```json
{"identifier":"user@example.com"}
```
响应:
```json
{"success": true, "message": "删除成功"}
```
如果账号不存在或删除失败,`success` 会是 `false``message` 会返回错误原因。
### `POST /admin/import`
批量导入 keys 与 accounts。
@@ -856,16 +897,25 @@ data: {"type":"message_stop"}
}
```
失败校验的账号会通过 `failed_accounts` 返回;成功保存到 Vercel 的凭据会通过 `saved_credentials` 返回。
### `GET /admin/vercel/status`
```json
{
"synced": true,
"last_sync_time": 1738400000,
"has_synced_before": true
"has_synced_before": true,
"env_backed": false,
"config_hash": "....",
"last_synced_hash": "....",
"draft_hash": "....",
"draft_differs": false
}
```
`POST /admin/vercel/status` 还可以携带 `config_override`,用于对比“草稿配置”和当前已同步配置。
### `GET /admin/export`
```json
@@ -875,6 +925,29 @@ data: {"type":"message_stop"}
}
```
该接口与 `GET /admin/config/export` 返回相同内容,只是路径更短。
### `GET /admin/version`
查询当前构建版本与 GitHub 最新 Release
```json
{
"success": true,
"current_version": "2.3.5",
"current_tag": "v2.3.5",
"source": "file:VERSION",
"checked_at": "2026-03-29T00:00:00Z",
"latest_tag": "v2.3.6",
"latest_version": "2.3.6",
"release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v2.3.6",
"published_at": "2026-03-28T12:00:00Z",
"has_update": true
}
```
如果 GitHub API 不可用,响应里会额外包含 `check_error`,但 HTTP 状态仍为 200。
### `GET /admin/dev/captures`
查看本地抓包状态与最近记录(需 Admin 鉴权):

View File

@@ -34,7 +34,7 @@ CMD ["/usr/local/bin/ds2api"]
FROM runtime-base AS runtime-from-source
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
COPY --from=go-builder /app/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
COPY --from=go-builder /app/internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm /app/sha3_wasm_bg.7b9ca65ddd.wasm
COPY --from=go-builder /app/config.example.json /app/config.example.json
COPY --from=webui-builder /app/static/admin /app/static/admin

View File

@@ -8,7 +8,7 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
@@ -16,6 +16,14 @@
将 DeepSeek Web 对话能力转换为 OpenAI、Claude 与 Gemini 兼容 API。后端为 **Go 全量实现**,前端为 React WebUI 管理台(源码在 `webui/`,部署时自动构建到 `static/admin`)。
> **重要免责声明**
>
> 本仓库仅供学习、研究、个人实验和内部验证使用,不提供任何形式的商业授权、适用性保证或结果保证。
>
> 作者及仓库维护者不对因使用、修改、分发、部署或依赖本项目而产生的任何直接或间接损失、账号封禁、数据丢失、法律风险或第三方索赔负责。
>
> 请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。
## 架构概览
```mermaid
@@ -68,7 +76,7 @@ flowchart LR
| 并发队列控制 | 每账号 in-flight 上限 + 等待队列,动态计算建议并发值 |
| DeepSeek PoW | WASM 计算(`wazero`),无需外部 Node.js 依赖 |
| Tool Calling | 防泄漏处理:非代码块高置信特征识别、`delta.tool_calls` 早发、结构化增量输出 |
| Admin API | 配置管理、运行时设置热更新、账号测试 / 批量测试、导入导出、Vercel 同步 |
| Admin API | 配置管理、运行时设置热更新、账号测试 / 批量测试、会话清理、导入导出、Vercel 同步、版本检查 |
| WebUI 管理台 | `/admin` 单页应用(中英文双语、深色模式) |
| 运维探针 | `GET /healthz`(存活)、`GET /readyz`(就绪) |
@@ -132,6 +140,7 @@ cp config.example.json config.json
后续部署建议:
- 本地运行:直接读取 `config.json`
- Docker / Vercel由 `config.json` 生成 `DS2API_CONFIG_JSON`Base64注入环境变量
- 兼容写法:`DS2API_CONFIG_JSON` 也可以直接写原始 JSON`CONFIG_JSON` 是旧版回退变量
### 方式一:本地运行
@@ -152,7 +161,7 @@ go run ./cmd/ds2api
默认监听地址:`http://localhost:5001`
> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm install && npm run build`(需要本机有 Node.js。你也可以手动构建`./scripts/build-webui.sh`
> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js。你也可以手动构建`./scripts/build-webui.sh`
### 方式二Docker 运行
@@ -170,6 +179,8 @@ docker-compose up -d
docker-compose logs -f
```
默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。
更新镜像:`docker-compose up -d --build`
#### Zeabur 一键部署Dockerfile
@@ -202,7 +213,7 @@ base64 < config.json | tr -d '\n'
> **流式说明**`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`Node Runtime以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。
详细部署说明请参阅 [部署指南](DEPLOY.md)。
详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。
### 方式四:下载 Release 构建包
@@ -259,10 +270,6 @@ cp opencode.json.example opencode.json
"compat": {
"wide_input_strict_output": true
},
"toolcall": {
"mode": "feature_match",
"early_emit_confidence": "high"
},
"responses": {
"store_ttl_seconds": 900
},
@@ -279,7 +286,8 @@ cp opencode.json.example opencode.json
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
"global_max_inflight": 0,
"token_refresh_interval_hours": 6
},
"auto_delete": {
"sessions": false
@@ -292,12 +300,12 @@ cp opencode.json.example opencode.json
- `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新
- `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
- `toolcall`固定采用特征匹配 + 高置信早发策略
- `toolcall`策略已固定为特征匹配 + 高置信早发,不再作为可配置项
- `responses.store_ttl_seconds``/v1/responses/{id}` 的内存缓存 TTL
- `embeddings.provider`embedding 提供方(当前内置 `deterministic/mock/builtin`
- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`
- `admin`管理后台设置JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新
- `runtime`:运行时参数(并发限制、队列大小),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算
- `runtime`:运行时参数(并发限制、队列大小、托管账号 token 刷新间隔),可通过 Admin Settings API 热更新;`account_max_queue=0`/`global_max_inflight=0` 表示按推荐值自动计算`token_refresh_interval_hours=6` 为默认强制重登间隔
- `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新)
### 环境变量
@@ -311,9 +319,13 @@ cp opencode.json.example opencode.json
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数 | `24` |
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — |
| `CONFIG_JSON` | 旧版兼容配置注入 | — |
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启Vercel 关闭 |
| `DS2API_DEV_PACKET_CAPTURE` | 本地开发抓包开关(记录最近会话请求/响应体) | 本地非 Vercel 默认开启 |
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` |
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` |
| `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号最大并发 in-flight 请求数 | `2` |
| `DS2API_ACCOUNT_CONCURRENCY` | 同上(兼容旧名) | — |
| `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` |
@@ -340,6 +352,7 @@ cp opencode.json.example opencode.json
| **直通 token 模式** | 传入 token 不在 `config.keys` 中时,直接作为 DeepSeek token 使用 |
可选请求头 `X-Ds2-Target-Account`:指定使用某个托管账号(值为 email 或 mobile
Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `?key=` / `?api_key=` 作为调用方凭据。
## 并发模型
@@ -356,13 +369,17 @@ cp opencode.json.example opencode.json
## Tool Call 适配
当请求中带 `tools` 时DS2API 会做防泄漏处理:
当请求中带 `tools` 时DS2API 会做防泄漏处理与结构化转译
1. 只在**非代码块上下文**启用 toolcall 特征识别(代码块示例不触发)
2. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
3. 未在 `tools` 声明中的工具名会被严格拒绝,不会下发为有效 tool call
1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发)
2. 解析层以 XML/Markup 为最高优先级,同时兼容 JSON / ANTML / invoke / text-kv并统一归一到内部工具调用结构
3. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
4. `responses` 支持并执行 `tool_choice``auto`/`none`/`required`/强制函数);`required` 违规时非流式返回 `422`,流式返回 `response.failed`
5. 仅在通过策略校验后才会发出有效工具调用事件,避免错误工具名进入客户端执行链
5. 客户端请求哪种协议就按该协议返回工具调用OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML再由兼容层转译
> 说明:当前版本在 parser 层仍以“尽量解析成功”为优先,未启用基于 allow-list 的工具名硬拒绝。
>
> 想评估“把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。
## 本地开发抓包工具
@@ -430,6 +447,7 @@ ds2api/
├── tests/
│ ├── compat/ # 兼容性测试夹具与期望输出
│ └── scripts/ # 统一测试脚本入口unit/e2e
├── docs/ # 部署 / 贡献 / 测试等辅助文档
├── static/admin/ # WebUI 构建产物(不提交到 Git
├── .github/
│ ├── workflows/ # GitHub Actions质量门禁 + Release 自动构建)
@@ -449,9 +467,9 @@ ds2api/
| 文档 | 说明 |
| --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API 接口文档(含请求/响应示例) |
| [DEPLOY.md](DEPLOY.md) / [DEPLOY.en.md](DEPLOY.en.md) | 部署指南(本地/Docker/Vercel/systemd |
| [CONTRIBUTING.md](CONTRIBUTING.md) / [CONTRIBUTING.en.md](CONTRIBUTING.en.md) | 贡献指南 |
| [TESTING.md](TESTING.md) | 测试集使用指南 |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | 部署指南(本地/Docker/Vercel/systemd |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | 贡献指南 |
| [TESTING.md](docs/TESTING.md) | 测试集使用指南 |
## 测试
@@ -481,7 +499,7 @@ npm ci --prefix webui && npm run build --prefix webui
## 测试
详细测试指南请参阅 [TESTING.md](TESTING.md)。
详细测试指南请参阅 [docs/TESTING.md](docs/TESTING.md)。
### 快速测试命令
@@ -507,4 +525,7 @@ go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
## 免责声明
本项目基于逆向方式实现,仅供学习研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。
本项目基于逆向方式实现,仅供学习研究、个人实验和内部验证使用,不提供任何商业授权、稳定性保证或可用性保证。
作者及仓库维护者不对因使用、修改、分发、部署或依赖本项目而产生的任何直接或间接损失、账号封禁、数据丢失、法律风险或第三方索赔负责。
请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。

View File

@@ -8,7 +8,7 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.en.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
@@ -16,6 +16,14 @@ Language: [中文](README.MD) | [English](README.en.md)
DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-compatible, and Gemini-compatible APIs. The backend is a **pure Go implementation**, with a React WebUI admin panel (source in `webui/`, build output auto-generated to `static/admin` during deployment).
> **Important Disclaimer**
>
> This repository is provided for learning, research, personal experimentation, and internal validation only. It does not grant any commercial authorization and comes with no warranty of fitness, stability, or results.
>
> The author and repository maintainers are not responsible for any direct or indirect loss, account suspension, data loss, legal risk, or third-party claims arising from use, modification, distribution, deployment, or reliance on this project.
>
> Do not use this project in ways that violate service terms, agreements, laws, or platform rules. Before any commercial use, review the `LICENSE`, the relevant terms, and confirm that you have the author's written permission.
## Architecture Overview
```mermaid
@@ -68,7 +76,7 @@ flowchart LR
| Concurrency control | Per-account in-flight limit + waiting queue, dynamic recommended concurrency |
| DeepSeek PoW | WASM solving via `wazero`, no external Node.js dependency |
| Tool Calling | Anti-leak handling: non-code-block feature match, early `delta.tool_calls`, structured incremental output |
| Admin API | Config management, runtime settings hot-reload, account testing/batch test, import/export, Vercel sync |
| Admin API | Config management, runtime settings hot-reload, account testing/batch test, session cleanup, import/export, Vercel sync, version check |
| WebUI Admin Panel | SPA at `/admin` (bilingual Chinese/English, dark mode) |
| Health Probes | `GET /healthz` (liveness), `GET /readyz` (readiness) |
@@ -132,6 +140,7 @@ cp config.example.json config.json
Recommended per deployment mode:
- Local run: read `config.json` directly
- Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`
- Compatibility note: `DS2API_CONFIG_JSON` may also contain raw JSON directly; `CONFIG_JSON` is the legacy fallback variable
### Option 1: Local Run
@@ -152,7 +161,7 @@ go run ./cmd/ds2api
Default URL: `http://localhost:5001`
> **WebUI auto-build**: On first local startup, if `static/admin` is missing, DS2API will auto-run `npm install && npm run build` (requires Node.js). You can also build manually: `./scripts/build-webui.sh`
> **WebUI auto-build**: On first local startup, if `static/admin` is missing, DS2API will auto-run `npm ci` (only when dependencies are missing) and `npm run build -- --outDir static/admin --emptyOutDir` (requires Node.js). You can also build manually: `./scripts/build-webui.sh`
### Option 2: Docker
@@ -170,6 +179,8 @@ docker-compose up -d
docker-compose logs -f
```
The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping.
Rebuild after updates: `docker-compose up -d --build`
#### Zeabur One-Click (Dockerfile)
@@ -202,7 +213,7 @@ base64 < config.json | tr -d '\n'
> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling.
For detailed deployment instructions, see the [Deployment Guide](DEPLOY.en.md).
For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md).
### Option 4: Download Release Binaries
@@ -259,10 +270,6 @@ cp opencode.json.example opencode.json
"compat": {
"wide_input_strict_output": true
},
"toolcall": {
"mode": "feature_match",
"early_emit_confidence": "high"
},
"responses": {
"store_ttl_seconds": 900
},
@@ -279,7 +286,8 @@ cp opencode.json.example opencode.json
"runtime": {
"account_max_inflight": 2,
"account_max_queue": 0,
"global_max_inflight": 0
"global_max_inflight": 0,
"token_refresh_interval_hours": 6
},
"auto_delete": {
"sessions": false
@@ -292,12 +300,12 @@ cp opencode.json.example opencode.json
- `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
- `toolcall`: Fixed to feature matching + high-confidence early emit, no longer configurable
- `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}`
- `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in)
- `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; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values
- `runtime`: Runtime parameters (concurrency limits, queue sizes, managed token refresh interval), hot-reloadable via Admin Settings API; `account_max_queue=0`/`global_max_inflight=0` means auto-calculate from recommended values, `token_refresh_interval_hours=6` is the default forced re-login interval
- `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings)
### Environment Variables
@@ -311,6 +319,7 @@ cp opencode.json.example opencode.json
| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours | `24` |
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
| `CONFIG_JSON` | Legacy compatibility config input | — |
| `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect |
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
@@ -340,6 +349,7 @@ For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports
| **Direct token** | If the token is not in `config.keys`, DS2API treats it as a DeepSeek token directly |
Optional header `X-Ds2-Target-Account`: Pin a specific managed account (value is email or mobile).
Gemini routes also accept `x-goog-api-key`, or `?key=` / `?api_key=` when no auth header is present.
## Concurrency Model
@@ -431,6 +441,7 @@ ds2api/
├── tests/
│ ├── compat/ # Compatibility fixtures and expected outputs
│ └── scripts/ # Unified test script entrypoints (unit/e2e)
├── docs/ # Deployment / contributing / testing docs
├── static/admin/ # WebUI build output (not committed to Git)
├── .github/
│ ├── workflows/ # GitHub Actions (quality gates + release automation)
@@ -450,9 +461,9 @@ ds2api/
| Document | Description |
| --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API reference with request/response examples |
| [DEPLOY.md](DEPLOY.md) / [DEPLOY.en.md](DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
| [CONTRIBUTING.md](CONTRIBUTING.md) / [CONTRIBUTING.en.md](CONTRIBUTING.en.md) | Contributing guide |
| [TESTING.md](TESTING.md) | Testsuite guide |
| [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
| [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contributing guide |
| [TESTING.md](docs/TESTING.md) | Testsuite guide |
## Testing
@@ -491,4 +502,7 @@ Workflow: `.github/workflows/release-artifacts.yml`
## Disclaimer
This project is built through reverse engineering and is provided for learning and research only. Stability is not guaranteed. Do not use it in scenarios that violate terms of service or laws.
This project is built through reverse engineering and is provided for learning, research, personal experimentation, and internal validation only. No commercial authorization is granted, and no warranty of stability, fitness, or results is provided.
The author and repository maintainers are not responsible for any direct or indirect loss, account suspension, data loss, legal risk, or third-party claims arising from use, modification, distribution, deployment, or reliance on this project.
Do not use this project in ways that violate service terms, agreements, laws, or platform rules. Before any commercial use, review the `LICENSE`, the relevant terms, and confirm that you have the author's written permission.

View File

@@ -1 +1 @@
2.4.0
2.5.1

View File

@@ -70,6 +70,7 @@ docker-compose -f docker-compose.dev.yml up
5. Open a Pull Request
> 💡 If you modify files under `webui/`, no manual build is needed — CI handles it automatically.
> If you want to verify the generated `static/admin/` assets locally, you can still run `./scripts/build-webui.sh`.
## Build WebUI
@@ -129,6 +130,8 @@ ds2api/
│ ├── components/ # Shared components
│ └── locales/ # Language packs
├── scripts/ # Build and test scripts
├── tests/ # Unit tests, Node tests, and end-to-end tests
├── plans/ # Plans, gates, and manual smoke-test records
├── static/admin/ # WebUI build output (not committed)
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Production

View File

@@ -70,6 +70,7 @@ docker-compose -f docker-compose.dev.yml up
5. 发起 Pull Request
> 💡 如果修改了 `webui/` 目录下的文件无需手动构建——CI 会自动处理。
> 但如果你本地想验证 `static/admin/` 产物,还是可以手动运行 `./scripts/build-webui.sh`
## WebUI 构建
@@ -129,6 +130,8 @@ ds2api/
│ ├── components/ # 通用组件
│ └── locales/ # 语言包
├── scripts/ # 构建与测试脚本
├── tests/ # 单元测试、Node 测试与端到端测试
├── plans/ # 计划、门禁和手工烟测记录
├── static/admin/ # WebUI 构建产物(不提交)
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 生产环境

View File

@@ -32,6 +32,7 @@ Config source (choose one):
- **File**: `config.json` (recommended for local/Docker)
- **Environment variable**: `DS2API_CONFIG_JSON` (recommended for Vercel; supports raw JSON or Base64)
- Compatibility note: `CONFIG_JSON` is the legacy fallback variable; `DS2API_CONFIG_JSON` may also contain raw JSON directly
Unified recommendation (best practice):
@@ -69,7 +70,7 @@ Default address: `http://0.0.0.0:5001` (override with `PORT`).
### 1.2 WebUI Build
On first local startup, if `static/admin/` is missing, DS2API will automatically attempt to build the WebUI (requires Node.js/npm).
On first local startup, if `static/admin/` is missing, DS2API will automatically attempt to build the WebUI (requires Node.js/npm; when dependencies are missing it runs `npm ci` first, then `npm run build -- --outDir static/admin --emptyOutDir`).
Manual build:
@@ -123,6 +124,8 @@ docker-compose up -d
docker-compose logs -f
```
The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, adjust the `ports` mapping.
### 2.2 Update
```bash
@@ -453,8 +456,8 @@ server {
# Copy compiled binary and related files to target directory
sudo mkdir -p /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/
# Optional: if you want to use an external WASM file (override the embedded one, from a release package or build output)
# sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -32,6 +32,7 @@
- **文件方式**`config.json`(推荐本地/Docker 使用)
- **环境变量方式**`DS2API_CONFIG_JSON`(推荐 Vercel 使用,支持 JSON 字符串或 Base64 编码)
- 兼容写法:`CONFIG_JSON` 是旧版回退变量;`DS2API_CONFIG_JSON` 也可以直接写原始 JSON
统一建议(最优实践):
@@ -69,7 +70,7 @@ go run ./cmd/ds2api
### 1.2 WebUI 构建
本地首次启动时,若 `static/admin/` 不存在,服务会自动尝试构建 WebUI需要 Node.js/npm
本地首次启动时,若 `static/admin/` 不存在,服务会自动尝试构建 WebUI需要 Node.js/npm;缺依赖时会先执行 `npm ci`,再执行 `npm run build -- --outDir static/admin --emptyOutDir`)。
你也可以手动构建:
@@ -123,6 +124,8 @@ docker-compose up -d
docker-compose logs -f
```
默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请调整 `ports` 配置。
### 2.2 更新
```bash
@@ -453,8 +456,8 @@ server {
# 将编译好的二进制文件和相关文件复制到目标目录
sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json /opt/ds2api/
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本)
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本,来自 release 包或构建产物
# sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin
```

View File

@@ -1,6 +1,6 @@
# DS2API 测试指南
语言 / Language: [中文 + English](TESTING.md)
语言 / Language: 中文 + English(同页)
## 概述 | Overview
@@ -14,6 +14,7 @@ DS2API 提供两个层级的测试:
| 端到端测试 | `./tests/scripts/run-live.sh` | 使用真实账号执行全链路测试 |
端到端测试集会录制完整的请求/响应日志,用于故障排查。
Node 单元测试脚本会先做 `node --check` 语法门禁,再以 `--test-concurrency=1` 串行执行测试文件,减少模块级共享状态带来的干扰。
---
@@ -66,6 +67,8 @@ DS2API 提供两个层级的测试:
4. **结果收集**:继续执行所有用例(不中断),写入最终汇总
如果你只想跳过这些 preflight 检查,可以直接运行 `go run ./cmd/ds2api-tests --no-preflight`
---
## CLI 参数 | CLI Flags

View File

@@ -1,41 +1,72 @@
# Tool call parsing semantics (Go canonical spec)
# Tool call parsing semanticsGo/Node 统一语义)
This document defines the cross-runtime contract for `ParseToolCallsDetailed` / `parseToolCallsDetailed`.
本文档描述当前代码中 `ParseToolCallsDetailed` / `parseToolCallsDetailed` 的**实际行为**,用于对齐 Go 与 Node Runtime。
## Output contract
## 1) 输出结构(当前实现)
- `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.
- `calls`:解析得到的工具调用列表(`name` + `input`)。
- `sawToolCallSyntax`:检测到工具调用语法特征时为 `true`(例如 `tool_calls``<tool_call>``<function_call>``<invoke>``function.name:`)。
- `rejectedByPolicy`:当前实现固定为 `false`(预留字段,尚未启用 allow-list 拒绝)。
- `rejectedToolNames`:当前实现固定为空数组(预留字段)。
## Parse pipeline
> 说明:`filterToolCallsDetailed` 当前仅做结构清洗,不做工具名策略拒绝。
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.
## 2) 解析管线
## Name normalization policy
1. **示例保护**:若判定为 fenced code block 示例上下文,则跳过执行型解析。
2. **候选片段构建**:从完整文本中构建候选(原文、围绕 `tool_calls` 的 JSON 片段、首尾大括号切片等)。
3. **按序尝试解析(命中即停)**
- 对“明显 JSON 工具载荷候选”(以 `{`/`[` 开头且包含 `tool_calls`/`\"function\"`)先走 JSON 解析,避免 JSON 字符串内偶发 XML 片段误命中;
- 其余候选优先 XML 解析(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / `antml:function_call` 等);
- JSON 解析(`{"tool_calls": [...]}`、列表、单对象);
- Markup 解析;
- Text-KV 回退(如 `function.name:` + `function.arguments:`)。
4. **兜底**:候选全部失败后,再对全文做 XML / Text-KV 回退。
When matching parsed names against configured tools:
## 3) XML 能力边界(当前)
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).
当前已支持输入端的“多 XML/标记风格”解析,包括但不限于:
## Standalone mode
- `<tool_call><tool_name>...</tool_name><parameters>...</parameters></tool_call>`
- `<function_call>tool</function_call><function parameter name="x">...</function parameter>`
- `<invoke name="tool"><parameter name="x">...</parameter></invoke>`
- `antml:function_call` / `antml:argument` / `antml:parameters`
- `tool_use` 家族标签
Standalone mode (`ParseStandaloneToolCallsDetailed`) parses the whole input directly (no candidate slicing), while still applying:
但**输出端仍统一转换为 OpenAI 兼容 JSON 事件/对象**`message.tool_calls``delta.tool_calls``response.function_call_arguments.*`)。
- example-context guard,
- JSON then markup fallback,
- the same allow-list normalization policy.
## 4) 关于“是否可以封装成 XML 再喂给模型”
结论:**可以做,而且当前解析器已经能兼容 XML 作为输入格式之一**,但代码里并没有 `toolcall.prefer_xml_output` 这个开关。现有可调配置只有:
- `toolcall.mode``feature_match` / `off`
- `toolcall.early_emit_confidence``high` / `low` / `off`
推荐思路仍然是“输入兼容层 + 输出按客户端协议渲染”:
1. **Prompt 约束层**:如果你要尝试 XML-first可以在系统提示词里约束模型输出规范 XML tool block例如 `<tool_calls><tool_call>...</tool_call></tool_calls>`)。
2. **解析兼容层**:继续在 parser 中同时接受 JSON / XML / ANTML / invoke / text-kv。
3. **协议归一层**:无论模型输出什么格式,统一落到内部 `ParsedToolCall`
4. **对外渲染层**根据客户端请求协议渲染OpenAI / Claude / Gemini 各自格式)。
这样可以同时获得:
- 减少模型端 JSON 转义/引号错误;
- 不破坏现有 SDK / 客户端生态;
- 逐步灰度(按模型、按租户、按请求开关)。
## 5) 落地建议(低风险迭代)
- 继续使用现有的 `toolcall.mode=feature_match``toolcall.early_emit_confidence=high` 作为默认策略。
- 如果要试 XML-first把它放在 prompt 层或上游模板层,不要假设代码里已有专门的 XML 输出开关。
- 增加观测指标:
- `toolcall_parse_source`json/xml/markup/textkv
- `toolcall_parse_success_rate`
- `toolcall_malformed_rate`
- `toolcall_repair_rate`
- 先在 `responses` 链路灰度,再扩展 `chat.completions`
## 6) 兼容性提醒
- 上游模型若输出混合文本 + XML仍可能出现“半结构化”噪声需要依赖现有 sieve 增量消费策略。
- XML 不等于安全:仍需做 tool 名、参数 schema、执行权限的服务端校验。

View File

@@ -358,6 +358,40 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
}
}
func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T) {
h := &Handler{}
payload := "I'll call a tool now.\\n<tool_use><tool_name>write_file</tool_name><parameters>{\\\"path\\\":\\\"/tmp/a.txt\\\",\\\"content\\\":\\\"abc\\\"}</parameters></tool_use>"
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/content","v":"`+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{"write_file"})
frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" && contentBlock["name"] == "write_file" {
foundToolUse = true
break
}
}
if !foundToolUse {
t.Fatalf("expected tool_use block with leading prose payload, body=%s", rec.Body.String())
}
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
return
}
}
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
}
func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(

View File

@@ -93,8 +93,11 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "search_web") || !containsStr(content, `"arguments":"{\"query\":\"latest\"}"`) {
t.Fatalf("expected assistant content to include serialized tool call for prompt roundtrip, got %q", content)
if !containsStr(content, "<tool_calls>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
}
if !containsStr(content, `<parameters>{"query":"latest"}</parameters>`) {
t.Fatalf("expected assistant content to include serialized parameters, got %q", content)
}
}
@@ -248,14 +251,11 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "tool_use") {
t.Fatalf("expected tool_use instruction in prompt")
if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt")
}
if containsStr(prompt, "TOOL_CALL_HISTORY") || containsStr(prompt, "TOOL_RESULT_HISTORY") {
t.Fatalf("expected legacy tool history markers removed from prompt")
}
if !containsStr(prompt, "Do not print tool-call JSON in text") {
t.Fatalf("expected prompt to keep no tool-call-json instruction")
if !containsStr(prompt, "TOOL CALL FORMAT") {
t.Fatalf("expected tool call format header in prompt")
}
}
@@ -301,12 +301,9 @@ func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) {
func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) {
tools := []any{"not a map"}
prompt := buildClaudeToolPrompt(tools)
if prompt == "" {
t.Fatal("expected non-empty prompt even with invalid tools")
}
// Should still contain the intro and instruction
if !containsStr(prompt, "You are Claude") {
t.Fatalf("expected intro in prompt")
// No valid tools → empty prompt
if prompt != "" {
t.Fatalf("expected empty prompt for non-map tools, got: %q", prompt)
}
}

View File

@@ -4,6 +4,9 @@ import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/prompt"
"ds2api/internal/util"
)
func normalizeClaudeMessages(messages []any) []any {
@@ -70,22 +73,27 @@ func normalizeClaudeMessages(messages []any) []any {
}
func buildClaudeToolPrompt(tools []any) string {
parts := []string{"You are Claude, a helpful AI assistant. You have access to these tools:"}
toolSchemas := make([]string, 0, len(tools))
names := make([]string, 0, len(tools))
for _, t := range tools {
m, ok := t.(map[string]any)
if !ok {
continue
}
name, desc, schemaObj := extractClaudeToolMeta(m)
if name == "" {
continue
}
names = append(names, name)
schema, _ := json.Marshal(schemaObj)
parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema))
}
parts = append(parts,
"When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.",
"Tool roundtrip context is included directly in the conversation messages (assistant tool_use/tool_calls and tool results).",
"After receiving a valid tool result, continue with final answer instead of repeating the same call unless required fields are still missing.",
)
return strings.Join(parts, "\n\n")
if len(toolSchemas) == 0 {
return ""
}
return "You have access to these tools:\n\n" +
strings.Join(toolSchemas, "\n\n") + "\n\n" +
util.BuildToolCallInstructions(names)
}
func formatClaudeToolResultForPrompt(block map[string]any) string {
@@ -146,7 +154,7 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
}
return map[string]any{
"role": "assistant",
"content": marshalCompactJSON(toolCalls),
"content": prompt.FormatToolCallsForPrompt(toolCalls),
"tool_calls": toolCalls,
}
}

View File

@@ -38,6 +38,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
}
finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"]))
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {
toolNames = []string{"__any_tool__"}
}
return claudeNormalizedRequest{
Standard: util.StandardRequest{

View File

@@ -8,7 +8,6 @@ import (
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/util"
)
type claudeStreamRuntime struct {
@@ -120,15 +119,6 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
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()

View File

@@ -1,6 +1,7 @@
package claude
import (
"encoding/json"
"fmt"
"time"
@@ -45,9 +46,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
finalText := s.text.String()
if s.bufferToolContent {
detected := util.ParseToolCalls(finalText, s.toolNames)
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, s.toolNames)
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {
stopReason = "tool_use"
@@ -60,9 +61,20 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
"type": "tool_use",
"id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), idx),
"name": tc.Name,
"input": tc.Input,
"input": map[string]any{},
},
})
inputBytes, _ := json.Marshal(tc.Input)
s.send("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": idx,
"delta": map[string]any{
"type": "input_json_delta",
"partial_json": string(inputBytes),
},
})
s.send("content_block_stop", map[string]any{
"type": "content_block_stop",
"index": idx,

View File

@@ -97,7 +97,7 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
finalText := sanitizeLeakedOutput(s.text.String())
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
@@ -141,7 +141,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
if evt.Content == "" {
continue
}
cleaned := sanitizeLeakedToolHistory(evt.Content)
cleaned := sanitizeLeakedOutput(evt.Content)
if cleaned == "" {
continue
}
@@ -250,7 +250,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
continue
}
if evt.Content != "" {
cleaned := sanitizeLeakedToolHistory(evt.Content)
cleaned := sanitizeLeakedOutput(evt.Content)
if cleaned == "" {
continue
}

View File

@@ -105,7 +105,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
result := sse.CollectStream(resp, thinkingEnabled, true)
finalThinking := result.Thinking
finalText := sanitizeLeakedToolHistory(result.Text)
finalText := sanitizeLeakedOutput(result.Text)
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
writeJSON(w, http.StatusOK, respBody)
}

View File

@@ -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 object format:\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON object above. Do NOT include any extra text.\n2) Do NOT wrap tool-call JSON in markdown/code fences (for example, do not use triple backticks).\n3) After receiving a tool result, you MUST use it to produce the final answer.\n4) Only call another tool when the previous result is missing required data or returned an error.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + buildToolCallInstructions(names)
if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n7) For this response, you MUST call at least one tool from the allowed list."
}
@@ -73,6 +73,11 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
return messages, names
}
// buildToolCallInstructions delegates to the shared util implementation.
func buildToolCallInstructions(toolNames []string) string {
return util.BuildToolCallInstructions(toolNames)
}
func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any {
if len(deltas) == 0 {
return nil
@@ -111,28 +116,21 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam
if len(deltas) == 0 {
return nil
}
allowed := namesToSet(allowedNames)
if len(allowed) == 0 {
for _, d := range deltas {
if d.Name != "" {
seenNames[d.Index] = "__blocked__"
}
}
return nil
}
out := make([]toolCallDelta, 0, len(deltas))
for _, d := range deltas {
if d.Name != "" {
if _, ok := allowed[d.Name]; !ok {
seenNames[d.Index] = "__blocked__"
continue
if seenNames != nil {
seenNames[d.Index] = d.Name
}
seenNames[d.Index] = d.Name
out = append(out, d)
continue
}
if seenNames == nil {
out = append(out, d)
continue
}
name := strings.TrimSpace(seenNames[d.Index])
if name == "" || name == "__blocked__" {
if name == "" {
continue
}
out = append(out, d)

View File

@@ -1,19 +1,9 @@
package openai
import "strings"
func (h *Handler) toolcallFeatureMatchEnabled() bool {
if h == nil || h.Store == nil {
return true
}
mode := strings.TrimSpace(strings.ToLower(h.Store.ToolcallMode()))
return mode == "" || mode == "feature_match"
return true
}
func (h *Handler) toolcallEarlyEmitHighConfidence() bool {
if h == nil || h.Store == nil {
return true
}
level := strings.TrimSpace(strings.ToLower(h.Store.ToolcallEarlyEmitConfidence()))
return level == "" || level == "high"
return true
}

View File

@@ -182,7 +182,7 @@ func TestHandleNonStreamToolCallInterceptsReasonerModel(t *testing.T) {
}
}
func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
func TestHandleNonStreamUnknownToolIntercepted(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -198,16 +198,13 @@ func TestHandleNonStreamUnknownToolNotIntercepted(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 for unknown schema name, got %#v", msg["tool_calls"])
}
content, _ := msg["content"].(string)
if !strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected unknown tool json to pass through as text, got %#v", content)
toolCalls, _ := msg["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected tool_calls for unknown schema name, got %#v", msg["tool_calls"])
}
}
@@ -413,7 +410,7 @@ func TestHandleStreamReasonerToolCallInterceptsWithoutRawContentLeak(t *testing.
}
}
func TestHandleStreamUnknownToolDoesNotLeakRawPayload(t *testing.T) {
func TestHandleStreamUnknownToolEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\",\"input\":{\"q\":\"go\"}}]}"}`,
@@ -428,18 +425,18 @@ func TestHandleStreamUnknownToolDoesNotLeakRawPayload(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 unknown schema name, body=%s", rec.Body.String())
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name, body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name: %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String())
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(t *testing.T) {
func TestHandleStreamUnknownToolNoArgsEmitsToolCall(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"{\"tool_calls\":[{\"name\":\"not_in_schema\"}]}"}`,
@@ -454,14 +451,14 @@ func TestHandleStreamUnknownToolNoArgsDoesNotLeakRawPayload(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 unknown schema name (no args), body=%s", rec.Body.String())
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta for unknown schema name (no args), body=%s", rec.Body.String())
}
if streamHasRawToolJSONContent(frames) {
t.Fatalf("did not expect raw tool_calls json leak for unknown schema name (no args): %s", rec.Body.String())
}
if streamFinishReason(frames) != "stop" {
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())
}
}

View File

@@ -0,0 +1,70 @@
package openai
import (
"regexp"
)
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`)
var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`)
// leakedMetaMarkerPattern matches DeepSeek special tokens in BOTH forms:
// - ASCII underscore: <end_of_sentence>
// - U+2581 variant: <end▁of▁sentence> (used in some DeepSeek outputs)
var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking)\s*[\|]>`)
// leakedAgentXMLBlockPatterns catch agent-style XML blocks that leak through
// when the sieve fails to capture them. These are applied only to complete
// wrapper blocks so standalone "<result>" examples in normal output remain
// untouched.
var leakedAgentXMLBlockPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?is)<attempt_completion\b[^>]*>(.*?)</attempt_completion>`),
regexp.MustCompile(`(?is)<ask_followup_question\b[^>]*>(.*?)</ask_followup_question>`),
regexp.MustCompile(`(?is)<new_task\b[^>]*>(.*?)</new_task>`),
}
var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)</?(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
var leakedAgentWrapperPlusResultOpenPattern = regexp.MustCompile(`(?is)<(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>\s*<result>`)
var leakedAgentResultPlusWrapperClosePattern = regexp.MustCompile(`(?is)</result>\s*</(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)</?result>`)
func sanitizeLeakedOutput(text string) string {
if text == "" {
return text
}
out := emptyJSONFencePattern.ReplaceAllString(text, "")
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
out = sanitizeLeakedAgentXMLBlocks(out)
return out
}
func sanitizeLeakedAgentXMLBlocks(text string) string {
out := text
for _, pattern := range leakedAgentXMLBlockPatterns {
out = pattern.ReplaceAllStringFunc(out, func(match string) string {
submatches := pattern.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
// Preserve the inner text so leaked agent instructions do not erase
// the actual answer, but strip the wrapper/result markup itself.
return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "")
})
}
// Fallback for truncated output streams: strip any dangling wrapper tags
// that were not part of a complete block replacement. If we detect leaked
// wrapper tags, strip only adjacent <result> tags to avoid exposing agent
// markup without altering unrelated user-visible <result> examples.
if leakedAgentWrapperTagPattern.MatchString(out) {
out = leakedAgentWrapperPlusResultOpenPattern.ReplaceAllStringFunc(out, func(match string) string {
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
})
out = leakedAgentResultPlusWrapperClosePattern.ReplaceAllStringFunc(out, func(match string) string {
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
})
out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "")
}
return out
}

View File

@@ -0,0 +1,68 @@
package openai
import "testing"
func TestSanitizeLeakedOutputRemovesEmptyJSONFence(t *testing.T) {
raw := "before\n```json\n```\nafter"
got := sanitizeLeakedOutput(raw)
if got != "before\n\nafter" {
t.Fatalf("unexpected sanitized empty json fence: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesLeakedWireToolCallAndResult(t *testing.T) {
raw := "开始\n[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"java -version\\\"}\",\"name\":\"exec\"},\"id\":\"callb9a321\",\"type\":\"function\"}]< | Tool | >{\"content\":\"openjdk version 21\",\"tool_call_id\":\"callb9a321\"}\n结束"
got := sanitizeLeakedOutput(raw)
if got != "开始\n\n结束" {
t.Fatalf("unexpected sanitize result for leaked wire format: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesStandaloneMetaMarkers(t *testing.T) {
raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<end▁of▁thinking>D<end▁of▁sentence>E"
got := sanitizeLeakedOutput(raw)
if got != "ABCDE" {
t.Fatalf("unexpected sanitize result for meta markers: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesAgentXMLLeaks(t *testing.T) {
raw := "Done.<attempt_completion><result>Some final answer</result></attempt_completion>"
got := sanitizeLeakedOutput(raw)
if got != "Done.Some final answer" {
t.Fatalf("unexpected sanitize result for agent XML leak: %q", got)
}
}
func TestSanitizeLeakedOutputPreservesStandaloneResultTags(t *testing.T) {
raw := "Example XML: <result>value</result>"
got := sanitizeLeakedOutput(raw)
if got != raw {
t.Fatalf("unexpected sanitize result for standalone result tag: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesDanglingAgentXMLOpeningTags(t *testing.T) {
raw := "Done.<attempt_completion><result>Some final answer"
got := sanitizeLeakedOutput(raw)
if got != "Done.Some final answer" {
t.Fatalf("unexpected sanitize result for dangling opening tags: %q", got)
}
}
func TestSanitizeLeakedOutputRemovesDanglingAgentXMLClosingTags(t *testing.T) {
raw := "Done.Some final answer</result></attempt_completion>"
got := sanitizeLeakedOutput(raw)
if got != "Done.Some final answer" {
t.Fatalf("unexpected sanitize result for dangling closing tags: %q", got)
}
}
func TestSanitizeLeakedOutputPreservesUnrelatedResultTagsWhenWrapperLeaks(t *testing.T) {
raw := "Done.<attempt_completion><result>Some final answer\nExample XML: <result>value</result>"
got := sanitizeLeakedOutput(raw)
want := "Done.Some final answer\nExample XML: <result>value</result>"
if got != want {
t.Fatalf("unexpected sanitize result for mixed leaked wrapper + xml example: %q", got)
}
}

View File

@@ -1,8 +1,6 @@
package openai
import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/prompt"
@@ -56,45 +54,24 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
}
func buildAssistantContentForPrompt(msg map[string]any) string {
content := normalizeOpenAIContentForPrompt(msg["content"])
toolCalls := normalizeAssistantToolCallsForPrompt(msg["tool_calls"])
if toolCalls == "" {
return strings.TrimSpace(content)
}
if strings.TrimSpace(content) == "" {
return toolCalls
}
return strings.TrimSpace(content + "\n" + toolCalls)
}
func normalizeAssistantToolCallsForPrompt(v any) string {
calls, ok := v.([]any)
if !ok || len(calls) == 0 {
content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"]))
toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"])
switch {
case content == "" && toolHistory == "":
return ""
case content == "":
return toolHistory
case toolHistory == "":
return content
default:
return content + "\n\n" + toolHistory
}
b, err := json.Marshal(calls)
if err != nil {
return strings.TrimSpace(fmt.Sprintf("%v", calls))
}
return strings.TrimSpace(string(b))
}
func buildToolContentForPrompt(msg map[string]any) string {
payload := map[string]any{
"content": msg["content"],
}
if id := strings.TrimSpace(asString(msg["tool_call_id"])); id != "" {
payload["tool_call_id"] = id
}
if id := strings.TrimSpace(asString(msg["id"])); id != "" {
payload["id"] = id
}
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
payload["name"] = name
}
content := normalizeOpenAIContentForPrompt(payload)
content := normalizeOpenAIContentForPrompt(msg["content"])
if strings.TrimSpace(content) == "" {
return `{"content":"null"}`
return "null"
}
return content
}
@@ -103,18 +80,6 @@ func normalizeOpenAIContentForPrompt(v any) string {
return prompt.NormalizeContent(v)
}
func normalizeToolArgumentString(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if looksLikeConcatenatedJSON(trimmed) {
// Keep original payload to avoid silent argument rewrites.
return raw
}
return trimmed
}
func normalizeOpenAIRoleForPrompt(role string) string {
role = strings.ToLower(strings.TrimSpace(role))
if role == "developer" {
@@ -129,20 +94,3 @@ func asString(v any) string {
}
return ""
}
func looksLikeConcatenatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false
}
if strings.Contains(trimmed, "}{") || strings.Contains(trimmed, "][") {
return true
}
dec := json.NewDecoder(strings.NewReader(trimmed))
var first any
if err := dec.Decode(&first); err != nil {
return false
}
var second any
return dec.Decode(&second) == nil
}

View File

@@ -35,19 +35,22 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 4 {
t.Fatalf("expected 4 normalized messages with assistant tool_call history preserved, got %d", len(normalized))
t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
}
toolContent, _ := normalized[3]["content"].(string)
if !strings.Contains(toolContent, `\"temp\":18`) {
t.Fatalf("tool result should be transparently forwarded, got %q", toolContent)
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
}
if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("tool history marker should not be injected: %q", toolContent)
if !strings.Contains(assistantContent, "<tool_name>get_weather</tool_name>") {
t.Fatalf("expected tool name in preserved history, got %q", assistantContent)
}
if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) {
t.Fatalf("tool result should be transparently forwarded, got %#v", normalized[3]["content"])
}
prompt := util.MessagesPrepare(normalized)
if strings.Contains(prompt, "[TOOL_CALL_HISTORY]") || strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("expected no synthetic history markers in prompt: %q", prompt)
if !strings.Contains(prompt, "<tool_calls>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
@@ -87,8 +90,8 @@ func TestNormalizeOpenAIMessagesForPrompt_ToolArrayBlocksJoined(t *testing.T) {
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"line-1"`) || !strings.Contains(got, `"line-2"`) || !strings.Contains(got, `"name":"read_file"`) {
t.Fatalf("expected tool envelope to preserve content blocks and metadata, got %q", got)
if !strings.Contains(got, `line-1`) || !strings.Contains(got, `line-2`) {
t.Fatalf("expected tool content blocks preserved, got %q", got)
}
}
@@ -112,7 +115,7 @@ func TestNormalizeOpenAIMessagesForPrompt_FunctionRoleCompatible(t *testing.T) {
t.Fatalf("expected function role normalized as tool, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"name":"legacy_tool"`) || !strings.Contains(got, `"ok":true`) {
if !strings.Contains(got, `"ok":true`) || strings.Contains(got, `"name":"legacy_tool"`) {
t.Fatalf("unexpected normalized function-role content: %q", got)
}
}
@@ -139,8 +142,8 @@ func TestNormalizeOpenAIMessagesForPrompt_EmptyToolContentPreservedAsNull(t *tes
t.Fatalf("expected tool role preserved, got %#v", normalized[0]["role"])
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"content":""`) || !strings.Contains(got, `"name":"noop_tool"`) || !strings.Contains(got, `"tool_call_id":"call_5"`) {
t.Fatalf("expected tool metadata preserved in content envelope, got %q", got)
if got != "null" {
t.Fatalf("expected empty tool content normalized as null string, got %q", got)
}
}
@@ -171,11 +174,14 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only message to be preserved, got %#v", normalized)
t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `"name":"search_web"`) || !strings.Contains(got, `"name":"eval_javascript"`) {
t.Fatalf("expected tool_calls payload preserved in assistant content, got %q", got)
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "<tool_call>") != 2 {
t.Fatalf("expected two preserved tool call blocks, got %q", content)
}
if !strings.Contains(content, "<tool_name>search_web</tool_name>") || !strings.Contains(content, "<tool_name>eval_javascript</tool_name>") {
t.Fatalf("expected both tool names in preserved history, got %q", content)
}
}
@@ -197,11 +203,11 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only content to be preserved, got %#v", normalized)
t.Fatalf("expected assistant tool_call-only content preserved, got %#v", normalized)
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, `{}{\"query\":\"测试工具调用\"}`) {
t.Fatalf("expected concatenated arguments preserved verbatim, got %q", got)
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `{}{"query":"测试工具调用"}`) {
t.Fatalf("expected concatenated tool arguments preserved, got %q", content)
}
}
@@ -222,12 +228,8 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe
}
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected assistant tool_calls history to be preserved even when name missing, got %#v", normalized)
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "call_missing_name") {
t.Fatalf("expected raw tool_call payload preserved, got %q", got)
if len(normalized) != 0 {
t.Fatalf("expected assistant tool_calls without text to be dropped when name is missing, got %#v", normalized)
}
}
@@ -250,11 +252,14 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 1 {
t.Fatalf("expected nil-content assistant tool_call-only message to be preserved, got %#v", normalized)
t.Fatalf("expected nil-content assistant tool_call-only message preserved, got %#v", normalized)
}
got, _ := normalized[0]["content"].(string)
if !strings.Contains(got, "send_file_to_user") {
t.Fatalf("expected tool call payload preserved, got %q", got)
content, _ := normalized[0]["content"].(string)
if strings.Contains(content, "null") {
t.Fatalf("expected no null literal injection, got %q", content)
}
if !strings.Contains(content, "<tool_calls>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}

View File

@@ -47,8 +47,11 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "[TOOL_CALL_HISTORY]") || strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") {
t.Fatalf("handler finalPrompt should not include synthetic history markers: %q", finalPrompt)
if !strings.Contains(finalPrompt, "<tool_calls>") {
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_name>get_weather</tool_name>") {
t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt)
}
}
@@ -71,16 +74,19 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "")
if !strings.Contains(finalPrompt, "After receiving a tool result, you MUST use it to produce the final answer.") {
if !strings.Contains(finalPrompt, "After receiving a tool result, use it directly.") {
t.Fatalf("vercel prepare finalPrompt missing final-answer instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Only call another tool when the previous result is missing required data or returned an error.") {
if !strings.Contains(finalPrompt, "Only call another tool if the result is insufficient.") {
t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Do NOT wrap tool-call JSON in markdown/code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence instruction: %q", finalPrompt)
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {
t.Fatalf("vercel prepare finalPrompt missing xml format instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "Do NOT wrap the XML in markdown code fences") {
t.Fatalf("vercel prepare finalPrompt missing no-fence xml instruction: %q", finalPrompt)
}
if strings.Contains(finalPrompt, "```json") {
t.Fatalf("vercel prepare finalPrompt should not require fenced json tool calls: %q", finalPrompt)
t.Fatalf("vercel prepare finalPrompt should not require fenced tool calls: %q", finalPrompt)
}
}

View File

@@ -113,7 +113,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
result := sse.CollectStream(resp, thinkingEnabled, true)
sanitizedText := sanitizeLeakedToolHistory(result.Text)
sanitizedText := sanitizeLeakedOutput(result.Text)
textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")

View File

@@ -1,11 +1,11 @@
package openai
import (
"encoding/json"
"fmt"
"strings"
"ds2api/internal/config"
"ds2api/internal/prompt"
)
func normalizeResponsesInputItem(m map[string]any) map[string]any {
@@ -148,7 +148,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str
functionPayload := map[string]any{
"name": name,
"arguments": stringifyToolCallArguments(argsRaw),
"arguments": prompt.StringifyToolCallArguments(argsRaw),
}
call := map[string]any{
"type": "function",
@@ -211,26 +211,3 @@ func normalizeResponsesFallbackPart(m map[string]any) string {
}
return strings.TrimSpace(fmt.Sprintf("%v", m))
}
func stringifyToolCallArguments(v any) string {
switch x := v.(type) {
case nil:
return "{}"
case string:
s := strings.TrimSpace(x)
if s == "" {
return "{}"
}
s = normalizeToolArgumentString(s)
if s == "" {
return "{}"
}
return s
default:
b, err := json.Marshal(x)
if err != nil || len(b) == 0 {
return "{}"
}
return string(b)
}
}

View File

@@ -97,7 +97,7 @@ func newResponsesStreamRuntime(
func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String()
finalText := sanitizeLeakedToolHistory(s.text.String())
finalText := sanitizeLeakedOutput(s.text.String())
if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
@@ -194,7 +194,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
continue
}
cleanedText := sanitizeLeakedToolHistory(p.Text)
cleanedText := sanitizeLeakedOutput(p.Text)
if cleanedText == "" {
continue
}

View File

@@ -354,7 +354,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *te
}
}
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
func TestHandleResponsesStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -376,8 +376,8 @@ func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, policy, "")
body := rec.Body.String()
if strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("did not expect function_call events for tool_choice=none, body=%s", body)
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected function_call events for tool_choice=none, body=%s", body)
}
}
@@ -518,7 +518,7 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) {
}
}
func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) {
func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
@@ -539,8 +539,8 @@ func TestHandleResponsesStreamRejectsUnknownToolName(t *testing.T) {
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, util.DefaultToolChoicePolicy(), "")
body := rec.Body.String()
if strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("did not expect function_call events for unknown tool, body=%s", body)
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected function_call events for unknown tool, body=%s", body)
}
}
@@ -597,7 +597,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t
}
}
func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {
func TestHandleResponsesNonStreamToolChoiceNoneStillAllowsFunctionCall(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
@@ -611,16 +611,20 @@ func TestHandleResponsesNonStreamToolChoiceNoneRejectsFunctionCall(t *testing.T)
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, policy, "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for tool_choice=none passthrough text, got %d body=%s", rec.Code, rec.Body.String())
t.Fatalf("expected 200 for tool_choice=none handling, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
output, _ := out["output"].([]any)
foundFunctionCall := false
for _, item := range output {
m, _ := item.(map[string]any)
if m != nil && m["type"] == "function_call" {
t.Fatalf("did not expect function_call output item for tool_choice=none, got %#v", output)
foundFunctionCall = true
}
}
if !foundFunctionCall {
t.Fatalf("expected function_call output item for tool_choice=none, got %#v", output)
}
}
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {

View File

@@ -25,6 +25,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
}
toolPolicy := util.DefaultToolChoicePolicy()
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
passThrough := collectOpenAIChatPassThrough(req)
return util.StandardRequest{
@@ -74,10 +75,8 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
return util.StandardRequest{}, err
}
finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy)
if toolPolicy.IsNone() {
toolNames = nil
toolPolicy.Allowed = nil
} else {
toolNames = ensureToolDetectionEnabled(toolNames, req["tools"])
if !toolPolicy.IsNone() {
toolPolicy.Allowed = namesToSet(toolNames)
}
passThrough := collectOpenAIChatPassThrough(req)
@@ -98,6 +97,20 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
}, nil
}
func ensureToolDetectionEnabled(toolNames []string, toolsRaw any) []string {
if len(toolNames) > 0 {
return toolNames
}
tools, _ := toolsRaw.([]any)
if len(tools) == 0 {
return toolNames
}
// Keep stream sieve/tool buffering enabled even when client tool schemas
// are malformed or lack explicit names; parsed tool payload names are no
// longer filtered by this list.
return []string{"__any_tool__"}
}
func collectOpenAIChatPassThrough(req map[string]any) map[string]any {
out := map[string]any{}
for _, k := range []string{

View File

@@ -152,7 +152,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceForcedUndeclaredFails(t *testi
}
}
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T) {
func TestNormalizeOpenAIResponsesRequestToolChoiceNoneKeepsToolDetectionEnabled(t *testing.T) {
store := newEmptyStoreForNormalizeTest(t)
req := map[string]any{
"model": "gpt-4o",
@@ -174,7 +174,7 @@ func TestNormalizeOpenAIResponsesRequestToolChoiceNoneDisablesTools(t *testing.T
if n.ToolChoice.Mode != util.ToolChoiceNone {
t.Fatalf("expected tool choice mode none, got %q", n.ToolChoice.Mode)
}
if len(n.ToolNames) != 0 {
t.Fatalf("expected no tool names when tool_choice=none, got %#v", n.ToolNames)
if len(n.ToolNames) == 0 {
t.Fatalf("expected tool detection sentinel when tool_choice=none, got %#v", n.ToolNames)
}
}

View File

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

View File

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

View File

@@ -114,8 +114,14 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea
} else {
content := state.capture.String()
if content != "" {
state.noteText(content)
events = append(events, toolStreamEvent{Content: content})
// If the captured text looks like an incomplete XML tool call block,
// swallow it to prevent leaking raw XML tags to the client.
if hasOpenXMLToolTag(content) {
// Drop it silently — incomplete tool call.
} else {
state.noteText(content)
events = append(events, toolStreamEvent{Content: content})
}
}
}
state.capture.Reset()
@@ -124,8 +130,14 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea
}
if state.pending.Len() > 0 {
content := state.pending.String()
state.noteText(content)
events = append(events, toolStreamEvent{Content: content})
// Safety: if pending contains XML tool tag fragments (e.g. "tool_calls>"
// from a split closing tag), swallow them instead of leaking.
if hasOpenXMLToolTag(content) || looksLikeXMLToolTagFragment(content) {
// Drop it — likely an incomplete tool call fragment.
} else {
state.noteText(content)
events = append(events, toolStreamEvent{Content: content})
}
state.pending.Reset()
}
return events
@@ -159,6 +171,10 @@ func findSuspiciousPrefixStart(s string) int {
start = idx
}
}
// Also check for partial XML tool tag at end of string.
if xmlIdx := findPartialXMLToolTagStart(s); xmlIdx >= 0 && xmlIdx > start {
start = xmlIdx
}
return start
}
@@ -167,7 +183,7 @@ func findToolSegmentStart(s string) int {
return -1
}
lower := strings.ToLower(s)
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
bestKeyIdx := -1
for _, kw := range keywords {
idx := strings.Index(lower, kw)
@@ -175,13 +191,32 @@ func findToolSegmentStart(s string) int {
bestKeyIdx = idx
}
}
// Also detect XML tool call tags.
for _, tag := range xmlToolTagsToDetect {
idx := strings.Index(lower, tag)
if idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx
}
}
if bestKeyIdx < 0 {
return -1
}
// For XML tags, the '<' is itself the segment start.
if bestKeyIdx < len(s) && s[bestKeyIdx] == '<' {
if fenceStart, ok := openFenceStartBefore(s, bestKeyIdx); ok {
return fenceStart
}
return bestKeyIdx
}
start := strings.LastIndex(s[:bestKeyIdx], "{")
if start < 0 {
start = bestKeyIdx
}
// If the keyword matched inside an XML tag (e.g. "tool_calls" in "<tool_calls>"),
// back up past the '<' to capture the full tag.
if start > 0 && s[start-1] == '<' {
start--
}
if fenceStart, ok := openFenceStartBefore(s, start); ok {
return fenceStart
}
@@ -193,9 +228,19 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
if captured == "" {
return "", nil, "", false
}
// Try XML tool call extraction first.
if xmlPrefix, xmlCalls, xmlSuffix, xmlReady := consumeXMLToolCapture(captured, toolNames); xmlReady {
return xmlPrefix, xmlCalls, xmlSuffix, true
}
// If XML tags are present but block is incomplete, keep buffering.
if hasOpenXMLToolTag(captured) {
return "", nil, "", false
}
lower := strings.ToLower(captured)
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]", "[tool_result_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
@@ -208,9 +253,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
}
start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 {
if blockStart, blockEnd, ok := extractToolHistoryBlock(captured, keyIdx); ok {
return captured[:blockStart], nil, captured[blockEnd:], true
}
start = keyIdx
}
obj, end, ok := extractJSONObjectFrom(captured, start)
@@ -234,67 +276,3 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
return prefixPart, parsed.Calls, suffixPart, true
}
func extractToolHistoryBlock(captured string, keyIdx int) (start int, end int, ok bool) {
if keyIdx < 0 || keyIdx >= len(captured) {
return 0, 0, false
}
rest := strings.ToLower(captured[keyIdx:])
switch {
case strings.HasPrefix(rest, "[tool_call_history]"):
closeTag := "[/tool_call_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
case strings.HasPrefix(rest, "[tool_result_history]"):
closeTag := "[/tool_result_history]"
closeIdx := strings.Index(rest, closeTag)
if closeIdx < 0 {
return 0, 0, false
}
return keyIdx, keyIdx + closeIdx + len(closeTag), true
default:
return 0, 0, false
}
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")
if fenceIdx < 0 {
return prefix, suffix
}
// Only strip when the trailing fence in prefix behaves like an opening fence.
// A legitimate closing fence before a standalone tool JSON must be preserved.
if strings.Count(trimmedPrefix[:fenceIdx+3], "```")%2 == 0 {
return prefix, suffix
}
fenceHeader := strings.TrimSpace(trimmedPrefix[fenceIdx+3:])
if fenceHeader != "" && !strings.EqualFold(fenceHeader, "json") {
return prefix, suffix
}
trimmedSuffix := strings.TrimLeft(suffix, " \t\r\n")
if !strings.HasPrefix(trimmedSuffix, "```") {
return prefix, suffix
}
consumedLeading := len(suffix) - len(trimmedSuffix)
return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:]
}
func openFenceStartBefore(s string, pos int) (int, bool) {
if pos <= 0 || pos > len(s) {
return -1, false
}
segment := s[:pos]
lastFence := strings.LastIndex(segment, "```")
if lastFence < 0 {
return -1, false
}
if strings.Count(segment, "```")%2 == 1 {
return lastFence, true
}
return -1, false
}

View File

@@ -1,5 +1,7 @@
package openai
import "strings"
func extractJSONObjectFrom(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '{' {
return "", 0, false
@@ -41,3 +43,42 @@ func extractJSONObjectFrom(text string, start int) (string, int, bool) {
}
return "", 0, false
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")
if fenceIdx < 0 {
return prefix, suffix
}
// Only strip when the trailing fence in prefix behaves like an opening fence.
// A legitimate closing fence before a standalone tool JSON must be preserved.
if strings.Count(trimmedPrefix[:fenceIdx+3], "```")%2 == 0 {
return prefix, suffix
}
fenceHeader := strings.TrimSpace(trimmedPrefix[fenceIdx+3:])
if fenceHeader != "" && !strings.EqualFold(fenceHeader, "json") {
return prefix, suffix
}
trimmedSuffix := strings.TrimLeft(suffix, " \t\r\n")
if !strings.HasPrefix(trimmedSuffix, "```") {
return prefix, suffix
}
consumedLeading := len(suffix) - len(trimmedSuffix)
return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:]
}
func openFenceStartBefore(s string, pos int) (int, bool) {
if pos <= 0 || pos > len(s) {
return -1, false
}
segment := s[:pos]
lastFence := strings.LastIndex(segment, "```")
if lastFence < 0 {
return -1, false
}
if strings.Count(segment, "```")%2 == 1 {
return lastFence, true
}
return -1, false
}

View File

@@ -34,7 +34,8 @@ type toolCallDelta struct {
Arguments string
}
const toolSieveContextTailLimit = 256
// Keep in sync with JS TOOL_SIEVE_CONTEXT_TAIL_LIMIT.
const toolSieveContextTailLimit = 2048
func (s *toolStreamSieveState) resetIncrementalToolState() {
s.disableDeltas = false

View File

@@ -0,0 +1,147 @@
package openai
import (
"regexp"
"strings"
"ds2api/internal/util"
)
// --- XML tool call support for the streaming sieve ---
var xmlToolCallClosingTags = []string{"</tool_calls>", "</tool_call>", "</invoke>", "</function_call>", "</function_calls>", "</tool_use>",
// Agent-style XML tags (Roo Code, Cline, etc.)
"</attempt_completion>", "</ask_followup_question>", "</new_task>", "</result>"}
var xmlToolCallOpeningTags = []string{"<tool_calls", "<tool_call", "<invoke", "<function_call", "<function_calls", "<tool_use",
// Agent-style XML tags
"<attempt_completion", "<ask_followup_question", "<new_task", "<result"}
// xmlToolCallTagPairs maps each opening tag to its expected closing tag.
// Order matters: longer/wrapper tags must be checked first.
var xmlToolCallTagPairs = []struct{ open, close string }{
{"<tool_calls", "</tool_calls>"},
{"<tool_call", "</tool_call>"},
{"<function_calls", "</function_calls>"},
{"<function_call", "</function_call>"},
{"<invoke", "</invoke>"},
{"<tool_use", "</tool_use>"},
// Agent-style: these are XML "tool call" patterns from coding agents.
// They get captured → parsed. If parsing fails, the block is consumed
// (swallowed) to prevent raw XML from leaking to the client.
{"<attempt_completion", "</attempt_completion>"},
{"<ask_followup_question", "</ask_followup_question>"},
{"<new_task", "</new_task>"},
}
// xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone).
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls>\s*(?:.*?)\s*</tool_calls>|<tool_call>\s*(?:.*?)\s*</tool_call>|<invoke\b[^>]*>(?:.*?)</invoke>|<function_calls?\b[^>]*>(?:.*?)</function_calls?>|<tool_use>(?:.*?)</tool_use>|<attempt_completion>(?:.*?)</attempt_completion>|<ask_followup_question>(?:.*?)</ask_followup_question>|<new_task>(?:.*?)</new_task>)`)
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_call>", "<tool_call\n",
"<invoke ", "<invoke>", "<function_call", "<function_calls", "<tool_use>",
// Agent-style tags
"<attempt_completion>", "<ask_followup_question>", "<new_task>"}
// consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text.
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
lower := strings.ToLower(captured)
// Find the FIRST matching open/close pair, preferring wrapper tags.
// Tag pairs are ordered longest-first (e.g. <tool_calls before <tool_call)
// so wrapper tags are checked before inner tags.
for _, pair := range xmlToolCallTagPairs {
openIdx := strings.Index(lower, pair.open)
if openIdx < 0 {
continue
}
// Find the LAST occurrence of the specific closing tag to get the outermost block.
closeIdx := strings.LastIndex(lower, pair.close)
if closeIdx < openIdx {
// Opening tag is present but its specific closing tag hasn't arrived.
// Return not-ready so we keep buffering — do NOT fall through to
// try inner pairs (e.g. <tool_call inside <tool_calls).
return "", nil, "", false
}
closeEnd := closeIdx + len(pair.close)
xmlBlock := captured[openIdx:closeEnd]
prefixPart := captured[:openIdx]
suffixPart := captured[closeEnd:]
parsed := util.ParseToolCalls(xmlBlock, toolNames)
if len(parsed) > 0 {
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
return prefixPart, parsed, suffixPart, true
}
// Looks like XML tool syntax but failed to parse — consume it to avoid leak.
return prefixPart, nil, suffixPart, true
}
return "", nil, "", false
}
// hasOpenXMLToolTag returns true if captured text contains an XML tool opening tag
// whose SPECIFIC closing tag has not appeared yet.
func hasOpenXMLToolTag(captured string) bool {
lower := strings.ToLower(captured)
for _, pair := range xmlToolCallTagPairs {
if strings.Contains(lower, pair.open) {
if !strings.Contains(lower, pair.close) {
return true
}
}
}
return false
}
// findPartialXMLToolTagStart checks if the string ends with a partial XML tool tag
// (e.g., "<tool_ca" or "<inv") and returns the position of the '<'.
func findPartialXMLToolTagStart(s string) int {
lastLT := strings.LastIndex(s, "<")
if lastLT < 0 {
return -1
}
tail := s[lastLT:]
// If there's a '>' in the tail, the tag is closed — not partial.
if strings.Contains(tail, ">") {
return -1
}
lowerTail := strings.ToLower(tail)
// Check if the tail is a prefix of any known XML tool tag.
for _, tag := range xmlToolCallOpeningTags {
tagWithLT := tag
if !strings.HasPrefix(tagWithLT, "<") {
tagWithLT = "<" + tagWithLT
}
if strings.HasPrefix(tagWithLT, lowerTail) {
return lastLT
}
}
return -1
}
// looksLikeXMLToolTagFragment returns true if s looks like a fragment from a
// split XML tool call tag — for example "tool_calls>" or "/tool_call>\n".
// These fragments arise when '<' was consumed separately and the tail remains.
func looksLikeXMLToolTagFragment(s string) bool {
trimmed := strings.TrimSpace(s)
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
// Check for closing tag tails like "tool_calls>" or "/tool_calls>"
fragments := []string{
"tool_calls>", "tool_call>", "/tool_calls>", "/tool_call>",
"function_calls>", "function_call>", "/function_calls>", "/function_call>",
"invoke>", "/invoke>", "tool_use>", "/tool_use>",
"tool_name>", "/tool_name>", "parameters>", "/parameters>",
// Agent-style tag fragments
"attempt_completion>", "/attempt_completion>",
"ask_followup_question>", "/ask_followup_question>",
"new_task>", "/new_task>",
"result>", "/result>",
}
for _, f := range fragments {
if strings.Contains(lower, f) {
return true
}
}
return false
}

View File

@@ -0,0 +1,319 @@
package openai
import (
"strings"
"testing"
)
func TestProcessToolSieveInterceptsXMLToolCallWithoutLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate a model producing XML tool call output chunk by chunk.
chunks := []string{
"<tool_calls>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
` <parameters>{"path":"README.MD"}</parameters>` + "\n",
" </tool_call>\n",
"</tool_calls>",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...)
}
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
var toolCalls int
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("XML tool call content leaked to text: %q", textContent)
}
if strings.Contains(textContent, "read_file") {
t.Fatalf("tool name leaked to text: %q", textContent)
}
if toolCalls == 0 {
t.Fatal("expected tool calls to be extracted, got none")
}
}
func TestProcessToolSieveXMLWithLeadingText(t *testing.T) {
var state toolStreamSieveState
// Model outputs some prose then an XML tool call.
chunks := []string{
"Let me check the file.\n",
"<tool_calls>\n <tool_call>\n <tool_name>read_file</tool_name>\n",
` <parameters>{"path":"go.mod"}</parameters>` + "\n </tool_call>\n</tool_calls>",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...)
}
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
var toolCalls int
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
toolCalls += len(evt.ToolCalls)
}
// Leading text should be emitted.
if !strings.Contains(textContent, "Let me check the file.") {
t.Fatalf("expected leading text to be emitted, got %q", textContent)
}
// The XML itself should NOT leak.
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("XML tool call content leaked to text: %q", textContent)
}
if toolCalls == 0 {
t.Fatal("expected tool calls to be extracted, got none")
}
}
func TestProcessToolSievePartialXMLTagHeldBack(t *testing.T) {
var state toolStreamSieveState
// Chunk ends with a partial XML tool tag.
events := processToolSieveChunk(&state, "Hello <tool_ca", []string{"read_file"})
var textContent string
for _, evt := range events {
textContent += evt.Content
}
// "Hello " should be emitted, but "<tool_ca" should be held back.
if strings.Contains(textContent, "<tool_ca") {
t.Fatalf("partial XML tag should not be emitted, got %q", textContent)
}
if !strings.Contains(textContent, "Hello") {
t.Fatalf("expected 'Hello' text to be emitted, got %q", textContent)
}
}
func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
cases := []struct {
name string
input string
want int
}{
{"tool_calls_tag", "some text <tool_calls>\n", 10},
{"tool_call_tag", "prefix <tool_call>\n", 7},
{"invoke_tag", "text <invoke name=\"foo\">body</invoke>", 5},
{"function_call_tag", "<function_call name=\"foo\">body</function_call>", 0},
{"no_xml", "just plain text", -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := findToolSegmentStart(tc.input)
if got != tc.want {
t.Fatalf("findToolSegmentStart(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
}
func TestFindPartialXMLToolTagStart(t *testing.T) {
cases := []struct {
name string
input string
want int
}{
{"partial_tool_call", "Hello <tool_ca", 6},
{"partial_invoke", "Prefix <inv", 7},
{"partial_lt_only", "Text <", 5},
{"complete_tag", "Text <tool_call>done", -1},
{"no_lt", "plain text", -1},
{"closed_lt", "a < b > c", -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := findPartialXMLToolTagStart(tc.input)
if got != tc.want {
t.Fatalf("findPartialXMLToolTagStart(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
}
func TestHasOpenXMLToolTag(t *testing.T) {
if !hasOpenXMLToolTag("<tool_call>\n<tool_name>foo</tool_name>") {
t.Fatal("should detect open XML tool tag without closing tag")
}
if hasOpenXMLToolTag("<tool_call>\n<tool_name>foo</tool_name></tool_call>") {
t.Fatal("should return false when closing tag is present")
}
if hasOpenXMLToolTag("plain text without any XML") {
t.Fatal("should return false for plain text")
}
}
// Test the EXACT scenario the user reports: token-by-token streaming where
// <tool_calls> tag arrives in small pieces.
func TestProcessToolSieveTokenByTokenXMLNoLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate DeepSeek model generating tokens one at a time.
chunks := []string{
"<",
"tool",
"_calls",
">\n",
" <",
"tool",
"_call",
">\n",
" <",
"tool",
"_name",
">",
"read",
"_file",
"</",
"tool",
"_name",
">\n",
" <",
"parameters",
">",
`{"path"`,
`: "README.MD"`,
`}`,
"</",
"parameters",
">\n",
" </",
"tool",
"_call",
">\n",
"</",
"tool",
"_calls",
">",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...)
}
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
var toolCalls int
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("XML tool call content leaked to text in token-by-token mode: %q", textContent)
}
if strings.Contains(textContent, "tool_calls>") {
t.Fatalf("closing tag fragment leaked to text: %q", textContent)
}
if strings.Contains(textContent, "read_file") {
t.Fatalf("tool name leaked to text: %q", textContent)
}
if toolCalls == 0 {
t.Fatal("expected tool calls to be extracted, got none")
}
}
// Test that flushToolSieve on incomplete XML does NOT leak the raw XML content.
func TestFlushToolSieveIncompleteXMLDoesNotLeak(t *testing.T) {
var state toolStreamSieveState
// XML block starts but stream ends before completion.
chunks := []string{
"<tool_calls>\n",
" <tool_call>\n",
" <tool_name>read_file</tool_name>\n",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{"read_file"})...)
}
// Stream ends abruptly - flush should NOT dump raw XML.
events = append(events, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
}
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("incomplete XML leaked on flush: %q", textContent)
}
}
// Test that the opening tag "<tool_calls>\n " is NOT emitted as text content.
func TestOpeningXMLTagNotLeakedAsContent(t *testing.T) {
var state toolStreamSieveState
// First chunk is the opening tag - should be held, not emitted.
evts1 := processToolSieveChunk(&state, "<tool_calls>\n ", []string{"read_file"})
for _, evt := range evts1 {
if strings.Contains(evt.Content, "<tool_calls>") {
t.Fatalf("opening tag leaked on first chunk: %q", evt.Content)
}
}
// Remaining content arrives.
evts2 := processToolSieveChunk(&state, "<tool_call>\n <tool_name>read_file</tool_name>\n <parameters>{\"path\":\"README.MD\"}</parameters>\n </tool_call>\n</tool_calls>", []string{"read_file"})
evts2 = append(evts2, flushToolSieve(&state, []string{"read_file"})...)
var textContent string
var toolCalls int
allEvents := append(evts1, evts2...)
for _, evt := range allEvents {
if evt.Content != "" {
textContent += evt.Content
}
toolCalls += len(evt.ToolCalls)
}
if strings.Contains(textContent, "<tool_call") {
t.Fatalf("XML content leaked: %q", textContent)
}
if toolCalls == 0 {
t.Fatal("expected tool calls to be extracted")
}
}
func TestProcessToolSieveInterceptsAttemptCompletionLeak(t *testing.T) {
var state toolStreamSieveState
// Simulate an agent outputting attempt_completion XML tag
// which shouldn't leak to text output, even if it fails to parse as a valid tool.
chunks := []string{
"Done with task.\n",
"<attempt_completion>\n",
" <result>Here is the answer</result>\n",
"</attempt_completion>",
}
var events []toolStreamEvent
for _, c := range chunks {
events = append(events, processToolSieveChunk(&state, c, []string{"attempt_completion"})...)
}
events = append(events, flushToolSieve(&state, []string{"attempt_completion"})...)
var textContent string
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
}
if !strings.Contains(textContent, "Done with task.\n") {
t.Fatalf("expected leading text to be emitted, got %q", textContent)
}
if strings.Contains(textContent, "<attempt_completion>") || strings.Contains(textContent, "result>") {
t.Fatalf("agent XML tag content leaked to text: %q", textContent)
}
}

View File

@@ -93,18 +93,16 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque
}
leased = true
writeJSON(w, http.StatusOK, map[string]any{
"session_id": sessionID,
"lease_id": leaseID,
"model": stdReq.ResponseModel,
"final_prompt": stdReq.FinalPrompt,
"thinking_enabled": stdReq.Thinking,
"search_enabled": stdReq.Search,
"tool_names": stdReq.ToolNames,
"toolcall_feature_match": h.toolcallFeatureMatchEnabled(),
"toolcall_early_emit_high": h.toolcallEarlyEmitHighConfidence(),
"deepseek_token": a.DeepSeekToken,
"pow_header": powHeader,
"payload": payload,
"session_id": sessionID,
"lease_id": leaseID,
"model": stdReq.ResponseModel,
"final_prompt": stdReq.FinalPrompt,
"thinking_enabled": stdReq.Thinking,
"search_enabled": stdReq.Search,
"tool_names": stdReq.ToolNames,
"deepseek_token": a.DeepSeekToken,
"pow_header": powHeader,
"payload": payload,
})
}

View File

@@ -28,6 +28,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int
RuntimeTokenRefreshIntervalHours() int
AutoDeleteSessions() bool
}

View File

@@ -120,12 +120,6 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
next.ModelAliases[k] = v
}
}
if strings.TrimSpace(incoming.Toolcall.Mode) != "" {
next.Toolcall.Mode = incoming.Toolcall.Mode
}
if strings.TrimSpace(incoming.Toolcall.EarlyEmitConfidence) != "" {
next.Toolcall.EarlyEmitConfidence = incoming.Toolcall.EarlyEmitConfidence
}
if incoming.Responses.StoreTTLSeconds > 0 {
next.Responses.StoreTTLSeconds = incoming.Responses.StoreTTLSeconds
}
@@ -150,6 +144,9 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
if incoming.Runtime.GlobalMaxInflight > 0 {
next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight
}
if incoming.Runtime.TokenRefreshIntervalHours > 0 {
next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours
}
}
normalizeSettingsConfig(&next)

View File

@@ -21,16 +21,15 @@ func boolFrom(v any) bool {
}
}
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) {
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *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
autoDeleteCfg *config.AutoDeleteConfig
claudeMap map[string]string
aliasMap map[string]string
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
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 {
@@ -38,7 +37,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, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
}
cfg.JWTExpireHours = n
}
@@ -50,59 +49,43 @@ 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, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
return 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, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
return 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, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
cfg.GlobalMaxInflight = n
}
if v, exists := raw["token_refresh_interval_hours"]; exists {
n := intFrom(v)
if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720")
}
cfg.TokenRefreshIntervalHours = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, 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, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
if raw, ok := req["toolcall"].(map[string]any); ok {
cfg := &config.ToolcallConfig{}
if v, exists := raw["mode"]; exists {
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch mode {
case "feature_match", "off":
cfg.Mode = mode
default:
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 {
level := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
switch level {
case "high", "low", "off":
cfg.EarlyEmitConfidence = level
default:
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
toolcallCfg = cfg
}
if raw, ok := req["responses"].(map[string]any); ok {
cfg := &config.ResponsesConfig{}
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if n < 30 || n > 86400 {
return nil, 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, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
cfg.StoreTTLSeconds = n
}
@@ -150,5 +133,5 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
}

View File

@@ -21,11 +21,11 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"default_password_warning": authn.UsingDefaultAdminKey(h.Store),
},
"runtime": map[string]any{
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
},
"toolcall": snap.Toolcall,
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,

View File

@@ -14,6 +14,9 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
if incoming.GlobalMaxInflight > 0 {
merged.GlobalMaxInflight = incoming.GlobalMaxInflight
}
if incoming.TokenRefreshIntervalHours > 0 {
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
}
}
return validateRuntimeSettings(merged)
}

View File

@@ -28,6 +28,25 @@ func TestGetSettingsDefaultPasswordWarning(t *testing.T) {
}
}
func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{"token_refresh_interval_hours":9}
}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
h.getSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
runtime, _ := body["runtime"].(map[string]any)
if got := intFrom(runtime["token_refresh_interval_hours"]); got != 9 {
t.Fatalf("expected token_refresh_interval_hours=9, got %d body=%v", got, body)
}
}
func TestUpdateSettingsValidation(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
@@ -44,6 +63,25 @@ func TestUpdateSettingsValidation(t *testing.T) {
}
}
func TestUpdateSettingsValidationRejectsTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"runtime": map[string]any{
"token_refresh_interval_hours": 0,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte("runtime.token_refresh_interval_hours")) {
t.Fatalf("expected token refresh validation detail, got %s", rec.Body.String())
}
}
func TestUpdateSettingsValidationWithMergedRuntimeSnapshot(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
@@ -126,6 +164,29 @@ func TestUpdateSettingsHotReloadRuntime(t *testing.T) {
}
}
func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{
"keys":["k1"],
"runtime":{"token_refresh_interval_hours":6}
}`)
payload := map[string]any{
"runtime": map[string]any{
"token_refresh_interval_hours": 12,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 12 {
t.Fatalf("token_refresh_interval_hours=%d want=12", got)
}
}
func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
hash := authn.HashAdminPassword("old-password")
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`)
@@ -207,6 +268,30 @@ func TestConfigImportMergeAndReplace(t *testing.T) {
}
}
func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
replace := map[string]any{
"mode": "replace",
"config": map[string]any{
"keys": []any{"k9"},
"runtime": map[string]any{
"token_refresh_interval_hours": 11,
},
},
}
replaceBytes, _ := json.Marshal(replace)
replaceReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=replace", bytes.NewReader(replaceBytes))
replaceRec := httptest.NewRecorder()
h.configImport(replaceRec, replaceReq)
if replaceRec.Code != http.StatusOK {
t.Fatalf("replace status=%d body=%s", replaceRec.Code, replaceRec.Body.String())
}
if got := h.Store.RuntimeTokenRefreshIntervalHours(); got != 11 {
t.Fatalf("token_refresh_interval_hours=%d want=11", got)
}
}
func TestConfigImportRejectsInvalidRuntimeBounds(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -45,13 +45,8 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if runtimeCfg.GlobalMaxInflight > 0 {
c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight
}
}
if toolcallCfg != nil {
if strings.TrimSpace(toolcallCfg.Mode) != "" {
c.Toolcall.Mode = strings.TrimSpace(toolcallCfg.Mode)
}
if strings.TrimSpace(toolcallCfg.EarlyEmitConfidence) != "" {
c.Toolcall.EarlyEmitConfidence = strings.TrimSpace(toolcallCfg.EarlyEmitConfidence)
if runtimeCfg.TokenRefreshIntervalHours > 0 {
c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
}
}
if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {

View File

@@ -12,8 +12,6 @@ func normalizeSettingsConfig(c *config.Config) {
return
}
c.Admin.PasswordHash = strings.TrimSpace(c.Admin.PasswordHash)
c.Toolcall.Mode = strings.ToLower(strings.TrimSpace(c.Toolcall.Mode))
c.Toolcall.EarlyEmitConfidence = strings.ToLower(strings.TrimSpace(c.Toolcall.EarlyEmitConfidence))
c.Embeddings.Provider = strings.TrimSpace(c.Embeddings.Provider)
}
@@ -27,20 +25,6 @@ func validateSettingsConfig(c config.Config) error {
if c.Responses.StoreTTLSeconds != 0 && (c.Responses.StoreTTLSeconds < 30 || c.Responses.StoreTTLSeconds > 86400) {
return fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
}
if mode := strings.TrimSpace(c.Toolcall.Mode); mode != "" {
switch mode {
case "feature_match", "off":
default:
return fmt.Errorf("toolcall.mode must be feature_match or off")
}
}
if level := strings.TrimSpace(c.Toolcall.EarlyEmitConfidence); level != "" {
switch level {
case "high", "low", "off":
default:
return fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
}
}
if c.Embeddings.Provider != "" && strings.TrimSpace(c.Embeddings.Provider) == "" {
return fmt.Errorf("embeddings.provider cannot be empty")
}
@@ -57,6 +41,9 @@ func validateRuntimeSettings(runtime config.RuntimeConfig) error {
if runtime.GlobalMaxInflight != 0 && (runtime.GlobalMaxInflight < 1 || runtime.GlobalMaxInflight > 200000) {
return fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
}
if runtime.TokenRefreshIntervalHours != 0 && (runtime.TokenRefreshIntervalHours < 1 || runtime.TokenRefreshIntervalHours > 720) {
return fmt.Errorf("runtime.token_refresh_interval_hours must be between 1 and 720")
}
if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}

View File

@@ -40,18 +40,16 @@ type Resolver struct {
Pool *account.Pool
Login LoginFunc
mu sync.Mutex
tokenRefreshedAt map[string]time.Time
tokenRefreshInterval time.Duration
mu sync.Mutex
tokenRefreshedAt map[string]time.Time
}
func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver {
return &Resolver{
Store: store,
Pool: pool,
Login: login,
tokenRefreshedAt: map[string]time.Time{},
tokenRefreshInterval: 6 * time.Hour,
Store: store,
Pool: pool,
Login: login,
tokenRefreshedAt: map[string]time.Time{},
}
}
@@ -232,10 +230,14 @@ func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error
}
func (r *Resolver) shouldForceRefresh(accountID string) bool {
if r == nil || r.Store == nil {
return false
}
if strings.TrimSpace(accountID) == "" {
return false
}
if r.tokenRefreshInterval <= 0 {
intervalHours := r.Store.RuntimeTokenRefreshIntervalHours()
if intervalHours <= 0 {
return false
}
now := time.Now()
@@ -246,7 +248,7 @@ func (r *Resolver) shouldForceRefresh(accountID string) bool {
r.tokenRefreshedAt[accountID] = now
return false
}
return now.Sub(last) >= r.tokenRefreshInterval
return now.Sub(last) >= time.Duration(intervalHours)*time.Hour
}
func (r *Resolver) markTokenRefreshedNow(accountID string) {

View File

@@ -244,3 +244,60 @@ func TestDetermineManagedAccountForcesRefreshEverySixHours(t *testing.T) {
t.Fatalf("expected exactly one forced refresh login, got %d", got)
}
}
func TestDetermineManagedAccountUsesUpdatedRefreshInterval(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[{"email":"acc@example.com","password":"pwd","token":"seed-token"}],
"runtime":{"token_refresh_interval_hours":6}
}`)
store := config.LoadStore()
if err := store.UpdateAccountToken("acc@example.com", "seed-token"); err != nil {
t.Fatalf("update token failed: %v", err)
}
pool := account.NewPool(store)
var loginCount int32
resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
n := atomic.AddInt32(&loginCount, 1)
return "fresh-token-" + string(rune('0'+n)), nil
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
a1, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine failed: %v", err)
}
if a1.DeepSeekToken != "seed-token" {
t.Fatalf("expected initial token without forced refresh, got %q", a1.DeepSeekToken)
}
resolver.Release(a1)
if got := atomic.LoadInt32(&loginCount); got != 0 {
t.Fatalf("expected no login before runtime update, got %d", got)
}
if err := store.Update(func(c *config.Config) error {
c.Runtime.TokenRefreshIntervalHours = 1
return nil
}); err != nil {
t.Fatalf("update runtime failed: %v", err)
}
resolver.mu.Lock()
resolver.tokenRefreshedAt["acc@example.com"] = time.Now().Add(-2 * time.Hour)
resolver.mu.Unlock()
a2, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine after runtime update failed: %v", err)
}
defer resolver.Release(a2)
if a2.DeepSeekToken != "fresh-token-1" {
t.Fatalf("expected refreshed token after runtime update, got %q", a2.DeepSeekToken)
}
if got := atomic.LoadInt32(&loginCount); got != 1 {
t.Fatalf("expected exactly one login after runtime update, got %d", got)
}
}

View File

@@ -32,15 +32,12 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Admin.PasswordHash) != "" || c.Admin.JWTExpireHours > 0 || c.Admin.JWTValidAfterUnix > 0 {
m["admin"] = c.Admin
}
if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 {
if c.Runtime.AccountMaxInflight > 0 || c.Runtime.AccountMaxQueue > 0 || c.Runtime.GlobalMaxInflight > 0 || c.Runtime.TokenRefreshIntervalHours > 0 {
m["runtime"] = c.Runtime
}
if c.Compat.WideInputStrictOutput != nil {
m["compat"] = c.Compat
}
if strings.TrimSpace(c.Toolcall.Mode) != "" || strings.TrimSpace(c.Toolcall.EarlyEmitConfidence) != "" {
m["toolcall"] = c.Toolcall
}
if c.Responses.StoreTTLSeconds > 0 {
m["responses"] = c.Responses
}
@@ -98,9 +95,7 @@ func (c *Config) UnmarshalJSON(b []byte) error {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "toolcall":
if err := json.Unmarshal(v, &c.Toolcall); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
// Legacy field ignored. Toolcall policy is fixed and no longer configurable.
case "responses":
if err := json.Unmarshal(v, &c.Responses); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -143,7 +138,6 @@ func (c Config) Clone() Config {
Compat: CompatConfig{
WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput),
},
Toolcall: c.Toolcall,
Responses: c.Responses,
Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,

View File

@@ -9,7 +9,6 @@ type Config struct {
Admin AdminConfig `json:"admin,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
@@ -62,14 +61,10 @@ type AdminConfig struct {
}
type RuntimeConfig struct {
AccountMaxInflight int `json:"account_max_inflight,omitempty"`
AccountMaxQueue int `json:"account_max_queue,omitempty"`
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
}
type ToolcallConfig struct {
Mode string `json:"mode,omitempty"`
EarlyEmitConfidence string `json:"early_emit_confidence,omitempty"`
AccountMaxInflight int `json:"account_max_inflight,omitempty"`
AccountMaxQueue int `json:"account_max_queue,omitempty"`
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
}
type ResponsesConfig struct {

View File

@@ -104,6 +104,9 @@ func TestConfigJSONRoundtrip(t *testing.T) {
"fast": "deepseek-chat",
"slow": "deepseek-reasoner",
},
Runtime: RuntimeConfig{
TokenRefreshIntervalHours: 12,
},
VercelSyncHash: "hash123",
VercelSyncTime: 1234567890,
AdditionalFields: map[string]any{
@@ -130,6 +133,9 @@ func TestConfigJSONRoundtrip(t *testing.T) {
if decoded.ClaudeMapping["fast"] != "deepseek-chat" {
t.Fatalf("unexpected claude mapping: %#v", decoded.ClaudeMapping)
}
if decoded.Runtime.TokenRefreshIntervalHours != 12 {
t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours)
}
if decoded.VercelSyncHash != "hash123" {
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
}

View File

@@ -79,6 +79,31 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
}
}
func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"email":"u@example.com","password":"p"}]
}`)
store := LoadStore()
if got := store.RuntimeTokenRefreshIntervalHours(); got != 6 {
t.Fatalf("expected default refresh interval 6, got %d", got)
}
}
func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"email":"u@example.com","password":"p"}],
"runtime":{"token_refresh_interval_hours":9}
}`)
store := LoadStore()
if got := store.RuntimeTokenRefreshIntervalHours(); got != 9 {
t.Fatalf("expected configured refresh interval 9, got %d", got)
}
}
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"email":"user@example.com","password":"p"}]

View File

@@ -43,23 +43,11 @@ func (s *Store) CompatWideInputStrictOutput() bool {
}
func (s *Store) ToolcallMode() string {
s.mu.RLock()
defer s.mu.RUnlock()
mode := strings.TrimSpace(strings.ToLower(s.cfg.Toolcall.Mode))
if mode == "" {
return "feature_match"
}
return mode
return "feature_match"
}
func (s *Store) ToolcallEarlyEmitConfidence() string {
s.mu.RLock()
defer s.mu.RUnlock()
level := strings.TrimSpace(strings.ToLower(s.cfg.Toolcall.EarlyEmitConfidence))
if level == "" {
return "high"
}
return level
return "high"
}
func (s *Store) ResponsesStoreTTLSeconds() int {
@@ -166,6 +154,15 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
return defaultSize
}
func (s *Store) RuntimeTokenRefreshIntervalHours() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.Runtime.TokenRefreshIntervalHours > 0 {
return s.cfg.Runtime.TokenRefreshIntervalHours
}
return 6
}
func (s *Store) AutoDeleteSessions() bool {
s.mu.RLock()
defer s.mu.RUnlock()

View File

@@ -8,13 +8,14 @@ const {
function resolveToolcallPolicy(prepBody, payloadTools) {
const preparedToolNames = normalizePreparedToolNames(prepBody && prepBody.tool_names);
const toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match);
const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
let toolNames = preparedToolNames.length > 0 ? preparedToolNames : extractToolNames(payloadTools);
if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) {
toolNames = ['__any_tool__'];
}
return {
toolNames,
toolSieveEnabled: toolNames.length > 0,
emitEarlyToolDeltas,
emitEarlyToolDeltas: true,
};
}
@@ -76,17 +77,6 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
return [];
}
const seen = seenNames instanceof Map ? seenNames : new Map();
const allowed = new Set((allowedNames || []).filter((name) => asString(name) !== ''));
if (allowed.size === 0) {
for (const d of deltas) {
if (d && typeof d === 'object' && asString(d.name)) {
const index = Number.isInteger(d.index) ? d.index : 0;
seen.set(index, '__blocked__');
}
}
return [];
}
const out = [];
for (const d of deltas) {
if (!d || typeof d !== 'object') {
@@ -95,16 +85,12 @@ function filterIncrementalToolCallDeltasByAllowed(deltas, allowedNames, seenName
const index = Number.isInteger(d.index) ? d.index : 0;
const name = asString(d.name);
if (name) {
if (!allowed.has(name)) {
seen.set(index, '__blocked__');
continue;
}
seen.set(index, name);
out.push(d);
continue;
}
const existing = asString(seen.get(index));
if (!existing || existing === '__blocked__') {
if (!existing) {
continue;
}
out.push(d);

View File

@@ -140,9 +140,33 @@ function extractJSONObjectFrom(text, start) {
return { ok: false, end: 0 };
}
function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
if (fenceIdx < 0) return { prefix, suffix };
const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length;
if (fenceCount % 2 === 0) {
return { prefix, suffix };
}
const header = rightTrimmedPrefix.slice(fenceIdx + 3).trim().toLowerCase();
if (header && header !== 'json') {
return { prefix, suffix };
}
const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, '');
if (!leftTrimmedSuffix.startsWith('```')) {
return { prefix, suffix };
}
const consumed = (suffix || '').length - leftTrimmedSuffix.length;
return {
prefix: rightTrimmedPrefix.slice(0, fenceIdx),
suffix: (suffix || '').slice(consumed + 3),
};
}
module.exports = {
findObjectFieldValueStart,
parseJSONStringLiteral,
skipSpaces,
extractJSONObjectFrom,
trimWrappingJSONFence,
};

View File

@@ -52,11 +52,26 @@ function parseToolCallsDetailed(text, toolNames) {
}
const candidates = buildToolCallCandidates(normalized);
for (const c of candidates) {
if (!isLikelyJSONToolPayloadCandidate(c)) {
continue;
}
const jsonParsed = parseToolCallsPayload(c);
if (jsonParsed.length === 0) {
continue;
}
result.sawToolCallSyntax = true;
const filteredJSON = filterToolCallsDetailed(jsonParsed, toolNames);
result.calls = filteredJSON.calls;
result.rejectedToolNames = filteredJSON.rejectedToolNames;
result.rejectedByPolicy = filteredJSON.rejectedToolNames.length > 0 && filteredJSON.calls.length === 0;
return result;
}
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
parsed = parseMarkupToolCalls(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
parsed = parseToolCallsPayload(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
@@ -101,9 +116,24 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
if (!isLikelyJSONToolPayloadCandidate(c)) {
continue;
}
parsed = parseToolCallsPayload(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
continue;
}
result.sawToolCallSyntax = true;
const filteredJSON = filterToolCallsDetailed(parsed, toolNames);
result.calls = filteredJSON.calls;
result.rejectedToolNames = filteredJSON.rejectedToolNames;
result.rejectedByPolicy = filteredJSON.rejectedToolNames.length > 0 && filteredJSON.calls.length === 0;
return result;
}
for (const c of candidates) {
parsed = parseMarkupToolCalls(c);
if (parsed.length === 0) {
parsed = parseToolCallsPayload(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
@@ -140,63 +170,17 @@ function emptyParseResult() {
}
function filterToolCallsDetailed(parsed, toolNames) {
const sourceNames = Array.isArray(toolNames) ? toolNames : [];
const allowed = new Set();
const allowedCanonical = new Map();
for (const item of sourceNames) {
const name = toStringSafe(item);
if (!name) {
continue;
}
allowed.add(name);
const lower = name.toLowerCase();
if (!allowedCanonical.has(lower)) {
allowedCanonical.set(lower, name);
}
}
if (allowed.size === 0) {
const rejected = [];
const seen = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
if (seen.has(tc.name)) {
continue;
}
seen.add(tc.name);
rejected.push(tc.name);
}
return { calls: [], rejectedToolNames: rejected };
}
const calls = [];
const rejected = [];
const seenRejected = new Set();
for (const tc of parsed) {
if (!tc || !tc.name) {
continue;
}
let matchedName = '';
if (allowed.has(tc.name)) {
matchedName = tc.name;
} else {
matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical);
}
if (!matchedName) {
if (!seenRejected.has(tc.name)) {
seenRejected.add(tc.name);
rejected.push(tc.name);
}
continue;
}
calls.push({
name: matchedName,
name: tc.name,
input: tc.input && typeof tc.input === 'object' && !Array.isArray(tc.input) ? tc.input : {},
});
}
return { calls, rejectedToolNames: rejected };
return { calls, rejectedToolNames: [] };
}
function resolveAllowedToolName(name, allowed, allowedCanonical) {
@@ -244,6 +228,18 @@ function shouldSkipToolCallParsingForCodeFenceExample(text) {
return !looksLikeToolCallSyntax(stripped);
}
function isLikelyJSONToolPayloadCandidate(text) {
const trimmed = toStringSafe(text).trim();
if (!trimmed) {
return false;
}
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) {
return false;
}
const lower = trimmed.toLowerCase();
return lower.includes('tool_calls') || lower.includes('"function"');
}
module.exports = {
extractToolNames,
parseToolCalls,

View File

@@ -6,6 +6,8 @@ const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/
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_:-]+:)?tool_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?tool_name>/i,
/<(?:[a-z0-9_:-]+:)?function_name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function_name>/i,
/<(?:[a-z0-9_:-]+:)?name\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?name>/i,
/<(?:[a-z0-9_:-]+:)?function\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?function>/i,
];
@@ -56,6 +58,11 @@ function buildToolCallCandidates(text) {
if (first >= 0 && last > first) {
candidates.push(toStringSafe(trimmed.slice(first, last + 1)));
}
const firstArr = trimmed.indexOf('[');
const lastArr = trimmed.lastIndexOf(']');
if (firstArr >= 0 && lastArr > firstArr) {
candidates.push(toStringSafe(trimmed.slice(firstArr, lastArr + 1)));
}
const m = trimmed.match(TOOL_CALL_PATTERN);
if (m && m[1]) {
@@ -76,7 +83,17 @@ function extractToolCallObjects(text) {
// eslint-disable-next-line no-constant-condition
while (true) {
let idx = lower.indexOf('tool_calls', offset);
const idxToolCalls = lower.indexOf('tool_calls', offset);
const idxFunction = lower.indexOf('"function"', offset);
let idx = -1;
let matched = '';
if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) {
idx = idxToolCalls;
matched = 'tool_calls';
} else if (idxFunction >= 0) {
idx = idxFunction;
matched = '"function"';
}
if (idx < 0) {
break;
}
@@ -85,14 +102,17 @@ function extractToolCallObjects(text) {
const obj = extractJSONObjectFrom(raw, start);
if (obj.ok) {
out.push(raw.slice(start, obj.end).trim());
offset = obj.end;
// Ensure forward progress even when the matched keyword is outside
// the extracted JSON object (e.g. closing XML wrapper tags containing
// "tool_calls" after an earlier JSON arguments object).
offset = Math.max(obj.end, idx + matched.length);
idx = -1;
break;
}
start = raw.slice(0, start).lastIndexOf('{');
}
if (idx >= 0) {
offset = idx + 'tool_calls'.length;
offset = idx + matched.length;
}
}

View File

@@ -0,0 +1,100 @@
'use strict';
const { parseToolCalls } = require('./parse');
// Tag pairs ordered longest-first: wrapper tags checked before inner tags.
const XML_TOOL_TAG_PAIRS = [
{ open: '<tool_calls', close: '</tool_calls>' },
{ open: '<tool_call', close: '</tool_call>' },
{ open: '<function_calls', close: '</function_calls>' },
{ open: '<function_call', close: '</function_call>' },
{ open: '<invoke', close: '</invoke>' },
{ open: '<tool_use', close: '</tool_use>' },
];
const XML_TOOL_OPENING_TAGS = XML_TOOL_TAG_PAIRS.map(p => p.open);
function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const lower = captured.toLowerCase();
// Find the FIRST matching open/close pair, preferring wrapper tags.
for (const pair of XML_TOOL_TAG_PAIRS) {
const openIdx = lower.indexOf(pair.open);
if (openIdx < 0) {
continue;
}
// Find the LAST occurrence of the specific closing tag.
const closeIdx = lower.lastIndexOf(pair.close);
if (closeIdx < openIdx) {
// Opening tag present but specific closing tag hasn't arrived.
// Return not-ready — do NOT fall through to inner pairs.
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const closeEnd = closeIdx + pair.close.length;
const xmlBlock = captured.slice(openIdx, closeEnd);
let prefixPart = captured.slice(0, openIdx);
let suffixPart = captured.slice(closeEnd);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: trimmedFence.prefix,
calls: parsed,
suffix: trimmedFence.suffix,
};
}
// XML tool syntax but failed to parse — consume to avoid leak.
return { ready: true, prefix: prefixPart, calls: [], suffix: suffixPart };
}
return { ready: false, prefix: '', calls: [], suffix: '' };
}
function hasOpenXMLToolTag(captured) {
const lower = captured.toLowerCase();
for (const pair of XML_TOOL_TAG_PAIRS) {
if (lower.includes(pair.open)) {
if (!lower.includes(pair.close)) {
return true;
}
}
}
return false;
}
function findPartialXMLToolTagStart(s) {
const lastLT = s.lastIndexOf('<');
if (lastLT < 0) {
return -1;
}
const tail = s.slice(lastLT);
if (tail.includes('>')) {
return -1;
}
const lowerTail = tail.toLowerCase();
for (const tag of XML_TOOL_OPENING_TAGS) {
const tagWithLT = tag.startsWith('<') ? tag : '<' + tag;
if (tagWithLT.startsWith(lowerTail)) {
return lastLT;
}
}
return -1;
}
function looksLikeXMLToolTagFragment(s) {
const trimmed = (s || '').trim();
if (!trimmed) return false;
const lower = trimmed.toLowerCase();
const fragments = [
'tool_calls>', 'tool_call>', '/tool_calls>', '/tool_call>',
'function_calls>', 'function_call>', '/function_calls>', '/function_call>',
'invoke>', '/invoke>', 'tool_use>', '/tool_use>',
'tool_name>', '/tool_name>', 'parameters>', '/parameters>',
];
return fragments.some(f => lower.includes(f));
}
module.exports = {
consumeXMLToolCapture,
hasOpenXMLToolTag,
findPartialXMLToolTagStart,
looksLikeXMLToolTagFragment,
};

View File

@@ -5,8 +5,18 @@ const {
insideCodeFenceWithState,
} = require('./state');
const { parseStandaloneToolCallsDetailed } = require('./parse');
const { extractJSONObjectFrom } = require('./jsonscan');
const { TOOL_SEGMENT_KEYWORDS, earliestKeywordIndex } = require('./tool-keywords');
const { extractJSONObjectFrom, trimWrappingJSONFence } = require('./jsonscan');
const {
TOOL_SEGMENT_KEYWORDS,
XML_TOOL_SEGMENT_TAGS,
earliestKeywordIndex,
} = require('./tool-keywords');
const {
consumeXMLToolCapture: consumeXMLToolCaptureImpl,
hasOpenXMLToolTag,
findPartialXMLToolTagStart,
looksLikeXMLToolTagFragment,
} = require('./sieve-xml');
function processToolSieveChunk(state, chunk, toolNames) {
if (!state) {
return [];
@@ -106,16 +116,21 @@ function flushToolSieve(state, toolNames) {
events.push({ type: 'text', text: consumed.suffix });
}
} else if (state.capture) {
noteText(state, state.capture);
events.push({ type: 'text', text: state.capture });
const content = state.capture;
if (!hasOpenXMLToolTag(content) && !looksLikeXMLToolTagFragment(content)) {
noteText(state, content);
events.push({ type: 'text', text: content });
}
}
state.capture = '';
state.capturing = false;
resetIncrementalToolState(state);
}
if (state.pending) {
noteText(state, state.pending);
events.push({ type: 'text', text: state.pending });
if (!hasOpenXMLToolTag(state.pending) && !looksLikeXMLToolTagFragment(state.pending)) {
noteText(state, state.pending);
events.push({ type: 'text', text: state.pending });
}
state.pending = '';
}
return events;
@@ -144,6 +159,11 @@ function findSuspiciousPrefixStart(s) {
start = idx;
}
}
// Also check for partial XML tool tag at end of string.
const xmlIdx = findPartialXMLToolTagStart(s);
if (xmlIdx >= 0 && xmlIdx > start) {
start = xmlIdx;
}
return start;
}
@@ -154,13 +174,35 @@ function findToolSegmentStart(state, s) {
const lower = s.toLowerCase();
let offset = 0;
while (true) {
const { index: bestKeyIdx, keyword: matchedKeyword } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS, offset);
// Check JSON keywords.
let { index: bestKeyIdx, keyword: matchedKeyword } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS, offset);
// Also check XML tool tags.
for (const tag of XML_TOOL_SEGMENT_TAGS) {
const idx = lower.indexOf(tag, offset);
if (idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx)) {
bestKeyIdx = idx;
matchedKeyword = tag;
}
}
if (bestKeyIdx < 0) {
return -1;
}
// For XML tags, the '<' is itself the segment start.
if (s[bestKeyIdx] === '<') {
if (!insideCodeFenceWithState(state, s.slice(0, bestKeyIdx))) {
return bestKeyIdx;
}
offset = bestKeyIdx + matchedKeyword.length;
continue;
}
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx;
let candidateStart = start >= 0 ? start : keyIdx;
// If the keyword matched inside an XML tag (e.g. "tool_calls" in "<tool_calls>"),
// back up past the '<' to capture the full tag.
if (candidateStart > 0 && s[candidateStart - 1] === '<') {
candidateStart--;
}
if (!insideCodeFenceWithState(state, s.slice(0, candidateStart))) {
return candidateStart;
}
@@ -173,6 +215,17 @@ function consumeToolCapture(state, toolNames) {
if (!captured) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
// Try XML tool call extraction first.
const xmlResult = consumeXMLToolCaptureImpl(captured, toolNames, trimWrappingJSONFence);
if (xmlResult.ready) {
return xmlResult;
}
// If XML tags are present but block is incomplete, keep buffering.
if (hasOpenXMLToolTag(captured)) {
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const lower = captured.toLowerCase();
const { index: keyIdx } = earliestKeywordIndex(lower, TOOL_SEGMENT_KEYWORDS);
if (keyIdx < 0) {
@@ -180,17 +233,6 @@ function consumeToolCapture(state, toolNames) {
}
const start = captured.slice(0, keyIdx).lastIndexOf('{');
const actualStart = start >= 0 ? start : keyIdx;
if (start < 0) {
const history = extractToolHistoryBlock(captured, keyIdx);
if (history.ok) {
return {
ready: true,
prefix: captured.slice(0, history.start),
calls: [],
suffix: captured.slice(history.end),
};
}
}
const obj = extractJSONObjectFrom(captured, actualStart);
if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' };
@@ -231,52 +273,6 @@ function consumeToolCapture(state, toolNames) {
};
}
function extractToolHistoryBlock(captured, keyIdx) {
if (typeof captured !== 'string' || keyIdx < 0 || keyIdx >= captured.length) {
return { ok: false, start: 0, end: 0 };
}
const rest = captured.slice(keyIdx).toLowerCase();
if (rest.startsWith('[tool_call_history]')) {
const closeTag = '[/tool_call_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
if (rest.startsWith('[tool_result_history]')) {
const closeTag = '[/tool_result_history]';
const closeIdx = rest.indexOf(closeTag);
if (closeIdx < 0) {
return { ok: false, start: 0, end: 0 };
}
return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length };
}
return { ok: false, start: 0, end: 0 };
}
function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
if (fenceIdx < 0) return { prefix, suffix };
const fenceCount = (rightTrimmedPrefix.slice(0, fenceIdx + 3).match(/```/g) || []).length;
if (fenceCount % 2 === 0) {
return { prefix, suffix };
}
const header = rightTrimmedPrefix.slice(fenceIdx + 3).trim().toLowerCase();
if (header && header !== 'json') {
return { prefix, suffix };
}
const leftTrimmedSuffix = (suffix || '').replace(/^[ \t\r\n]+/g, '');
if (!leftTrimmedSuffix.startsWith('```')) {
return { prefix, suffix };
}
const consumed = (suffix || '').length - leftTrimmedSuffix.length;
return {
prefix: rightTrimmedPrefix.slice(0, fenceIdx),
suffix: (suffix || '').slice(consumed + 3),
};
}
module.exports = {
processToolSieveChunk,
flushToolSieve,

View File

@@ -1,6 +1,7 @@
'use strict';
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096;
// Keep in sync with Go toolSieveContextTailLimit.
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048;
function createToolSieveState() {
return {

View File

@@ -2,9 +2,21 @@
const TOOL_SEGMENT_KEYWORDS = [
'tool_calls',
'"function"',
'function.name:',
'[tool_call_history]',
'[tool_result_history]',
];
const XML_TOOL_SEGMENT_TAGS = [
'<tool_calls>', '<tool_calls\n', '<tool_call>', '<tool_call\n',
'<invoke ', '<invoke>', '<function_call', '<function_calls', '<tool_use>',
];
const XML_TOOL_OPENING_TAGS = [
'<tool_calls', '<tool_call', '<invoke', '<function_call', '<function_calls', '<tool_use',
];
const XML_TOOL_CLOSING_TAGS = [
'</tool_calls>', '</tool_call>', '</invoke>', '</function_call>', '</function_calls>', '</tool_use>',
];
function earliestKeywordIndex(text, keywords = TOOL_SEGMENT_KEYWORDS, offset = 0) {
@@ -25,5 +37,8 @@ function earliestKeywordIndex(text, keywords = TOOL_SEGMENT_KEYWORDS, offset = 0
module.exports = {
TOOL_SEGMENT_KEYWORDS,
XML_TOOL_SEGMENT_TAGS,
XML_TOOL_OPENING_TAGS,
XML_TOOL_CLOSING_TAGS,
earliestKeywordIndex,
};

View File

@@ -42,12 +42,15 @@ func MessagesPrepare(messages []map[string]any) string {
} else {
parts = append(parts, m.Text)
}
case "user", "system":
if i > 0 {
parts = append(parts, "<User>"+m.Text)
} else {
parts = append(parts, m.Text)
case "system":
// Clear system boundary improves R1 and V3 context understanding significantly
if strings.TrimSpace(m.Text) != "" {
parts = append(parts, "<system_instructions>\n"+strings.TrimSpace(m.Text)+"\n</system_instructions>\n\n")
}
case "user":
// Always prepend <User> to user messages. DeepSeek R1 reasoning triggers best
// and aligns context perfectly when the user turn is explicitly marked.
parts = append(parts, "<User>"+m.Text)
default:
parts = append(parts, m.Text)
}

View File

@@ -0,0 +1,137 @@
package prompt
import (
"encoding/json"
"strings"
)
var promptXMLTextEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
// FormatToolCallsForPrompt renders a tool_calls slice into the canonical
// prompt-visible history block used across adapters.
func FormatToolCallsForPrompt(raw any) string {
calls, ok := raw.([]any)
if !ok || len(calls) == 0 {
return ""
}
blocks := make([]string, 0, len(calls))
for _, item := range calls {
call, ok := item.(map[string]any)
if !ok {
continue
}
block := formatToolCallForPrompt(call)
if block != "" {
blocks = append(blocks, block)
}
}
if len(blocks) == 0 {
return ""
}
return "<tool_calls>\n" + strings.Join(blocks, "\n") + "\n</tool_calls>"
}
// StringifyToolCallArguments normalizes tool arguments into a compact string
// while preserving raw concatenated payloads when they already look like model
// output rather than a single JSON object.
func StringifyToolCallArguments(v any) string {
switch x := v.(type) {
case nil:
return "{}"
case string:
s := strings.TrimSpace(x)
if s == "" {
return "{}"
}
s = normalizeToolArgumentString(s)
if s == "" {
return "{}"
}
return s
default:
b, err := json.Marshal(x)
if err != nil || len(b) == 0 {
return "{}"
}
return string(b)
}
}
func formatToolCallForPrompt(call map[string]any) string {
if call == nil {
return ""
}
name := strings.TrimSpace(asString(call["name"]))
fn, _ := call["function"].(map[string]any)
if name == "" && fn != nil {
name = strings.TrimSpace(asString(fn["name"]))
}
if name == "" {
return ""
}
argsRaw := call["arguments"]
if argsRaw == nil {
argsRaw = call["input"]
}
if argsRaw == nil && fn != nil {
argsRaw = fn["arguments"]
if argsRaw == nil {
argsRaw = fn["input"]
}
}
return " <tool_call>\n" +
" <tool_name>" + escapeXMLText(name) + "</tool_name>\n" +
" <parameters>" + escapeXMLText(StringifyToolCallArguments(argsRaw)) + "</parameters>\n" +
" </tool_call>"
}
func normalizeToolArgumentString(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if looksLikeConcatenatedJSON(trimmed) {
// Keep the original payload to avoid silently rewriting model output.
return raw
}
return trimmed
}
func looksLikeConcatenatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false
}
if strings.Contains(trimmed, "}{") || strings.Contains(trimmed, "][") {
return true
}
dec := json.NewDecoder(strings.NewReader(trimmed))
var first any
if err := dec.Decode(&first); err != nil {
return false
}
var second any
return dec.Decode(&second) == nil
}
func asString(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func escapeXMLText(v string) string {
if v == "" {
return ""
}
return promptXMLTextEscaper.Replace(v)
}

View File

@@ -0,0 +1,41 @@
package prompt
import "testing"
func TestStringifyToolCallArgumentsPreservesConcatenatedJSON(t *testing.T) {
got := StringifyToolCallArguments(`{}{"query":"测试工具调用"}`)
if got != `{}{"query":"测试工具调用"}` {
t.Fatalf("expected raw concatenated JSON to be preserved, got %q", got)
}
}
func TestFormatToolCallsForPromptXML(t *testing.T) {
got := FormatToolCallsForPrompt([]any{
map[string]any{
"id": "call_1",
"function": map[string]any{
"name": "search_web",
"arguments": map[string]any{"query": "latest"},
},
},
})
if got == "" {
t.Fatal("expected non-empty formatted tool calls")
}
if got != "<tool_calls>\n <tool_call>\n <tool_name>search_web</tool_name>\n <parameters>{\"query\":\"latest\"}</parameters>\n </tool_call>\n</tool_calls>" {
t.Fatalf("unexpected formatted tool call XML: %q", got)
}
}
func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) {
got := FormatToolCallsForPrompt([]any{
map[string]any{
"name": "search<&>",
"arguments": `{"q":"a < b && c > d"}`,
},
})
want := "<tool_calls>\n <tool_call>\n <tool_name>search&lt;&amp;&gt;</tool_name>\n <parameters>{\"q\":\"a &lt; b &amp;&amp; c &gt; d\"}</parameters>\n </tool_call>\n</tool_calls>"
if got != want {
t.Fatalf("unexpected escaped tool call XML: %q", got)
}
}

View File

@@ -12,7 +12,7 @@ func TestMessagesPrepareBasic(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty prompt")
}
if got != "Hello" {
if got != "<User>Hello" {
t.Fatalf("unexpected prompt: %q", got)
}
}
@@ -55,7 +55,7 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) {
},
}
got := MessagesPrepare(messages)
if got != "line1\nline2" {
if got != "<User>line1\nline2" {
t.Fatalf("unexpected content from text variants: %q", got)
}
}

View File

@@ -0,0 +1,99 @@
package util
// BuildToolCallInstructions generates the unified tool-calling instruction block
// used by all adapters (OpenAI, Claude, Gemini). It uses attention-optimized
// structure: rules → negative examples → positive examples → anchor.
//
// The toolNames slice should contain the actual tool names available in the
// current request; the function picks real names for examples.
func BuildToolCallInstructions(toolNames []string) string {
// Pick real tool names for examples; fall back to generic names.
ex1 := "read_file"
ex2 := "write_to_file"
ex3 := "ask_followup_question"
used := map[string]bool{}
for _, n := range toolNames {
switch {
// Read/query-type tools
case !used["ex1"] && matchAny(n, "read_file", "list_files", "search_files", "Read", "Glob"):
ex1 = n
used["ex1"] = true
// Write/execute-type tools
case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "Write", "Edit", "MultiEdit", "Bash"):
ex2 = n
used["ex2"] = true
// Interactive/meta tools
case !used["ex3"] && matchAny(n, "ask_followup_question", "attempt_completion", "update_todo_list", "Task"):
ex3 = n
used["ex3"] = true
}
}
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
When calling tools, emit ONLY raw XML. No text before, no text after, no markdown fences.
<tool_calls>
<tool_call>
<tool_name>TOOL_NAME_HERE</tool_name>
<parameters>{"key":"value"}</parameters>
</tool_call>
</tool_calls>
RULES:
1) Output ONLY the XML above when calling tools. Do NOT mix tool XML with regular text.
2) <parameters> MUST contain a strict JSON object. All JSON keys and strings use double quotes.
3) Multiple tools → multiple <tool_call> blocks inside ONE <tool_calls> root.
4) Do NOT wrap the XML in markdown code fences (no triple backticks).
5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient.
6) If you want to say something AND call a tool, output text first, then the XML block on its own.
❌ WRONG — Do NOT do these:
Wrong 1 — mixed text and XML:
I'll read the file for you. <tool_calls><tool_call>...
Wrong 2 — describing tool calls in text:
[调用 Bash] {"command": "ls"}
Wrong 3 — missing <tool_calls> wrapper:
<tool_call><tool_name>` + ex1 + `</tool_name><parameters>{}</parameters></tool_call>
✅ CORRECT EXAMPLES:
Example A — Single tool:
<tool_calls>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>{"path":"src/main.go"}</parameters>
</tool_call>
</tool_calls>
Example B — Two tools in parallel:
<tool_calls>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>{"path":"config.json"}</parameters>
</tool_call>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<parameters>{"path":"output.txt","content":"Hello world"}</parameters>
</tool_call>
</tool_calls>
Example C — Tool with complex nested JSON parameters:
<tool_calls>
<tool_call>
<tool_name>` + ex3 + `</tool_name>
<parameters>{"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]}</parameters>
</tool_call>
</tool_calls>
Remember: Output ONLY the <tool_calls>...</tool_calls> XML block when calling tools.`
}
func matchAny(name string, candidates ...string) bool {
for _, c := range candidates {
if name == c {
return true
}
}
return false
}

View File

@@ -30,6 +30,12 @@ func buildToolCallCandidates(text string) []string {
if first >= 0 && last > first {
candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1]))
}
// best-effort array slice: from first '[' to last ']'
firstArr := strings.Index(trimmed, "[")
lastArr := strings.LastIndex(trimmed, "]")
if firstArr >= 0 && lastArr > firstArr {
candidates = append(candidates, strings.TrimSpace(trimmed[firstArr:lastArr+1]))
}
// legacy regex extraction fallback
if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 {
@@ -58,7 +64,7 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text)
out := []string{}
offset := 0
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
for {
bestIdx := -1
matchedKeyword := ""

View File

@@ -16,6 +16,7 @@ type ToolCallParseResult struct {
RejectedByPolicy bool
RejectedToolNames []string
}
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseToolCallsDetailed(text, availableToolNames).Calls
}
@@ -31,15 +32,31 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
}
candidates := buildToolCallCandidates(text)
var parsed []ParsedToolCall
for _, candidate := range candidates {
if !isLikelyJSONToolPayloadCandidate(candidate) {
continue
}
tc := parseToolCallsPayload(candidate)
if len(tc) == 0 {
tc = parseXMLToolCalls(candidate)
continue
}
parsed := tc
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
result.SawToolCallSyntax = true
return result
}
var parsed []ParsedToolCall
for _, candidate := range candidates {
tc := parseXMLToolCalls(candidate)
if len(tc) == 0 {
tc = parseMarkupToolCalls(candidate)
}
if len(tc) == 0 {
tc = parseToolCallsPayload(candidate)
}
if len(tc) == 0 {
tc = parseTextKVToolCalls(candidate)
}
@@ -83,17 +100,32 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
if !isLikelyJSONToolPayloadCandidate(candidate) {
continue
}
parsed = parseToolCallsPayload(candidate)
if len(parsed) == 0 {
parsed = parseXMLToolCalls(candidate)
continue
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
parsed = parseXMLToolCalls(candidate)
if len(parsed) == 0 {
parsed = parseMarkupToolCalls(candidate)
}
if len(parsed) == 0 {
parsed = parseToolCallsPayload(candidate)
}
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(candidate)
}
@@ -119,56 +151,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
}
func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) {
allowed := map[string]struct{}{}
allowedCanonical := map[string]string{}
for _, name := range availableToolNames {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
allowed[trimmed] = struct{}{}
lower := strings.ToLower(trimmed)
if _, exists := allowedCanonical[lower]; !exists {
allowedCanonical[lower] = trimmed
}
}
if len(allowed) == 0 {
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 = 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 == "" {
if _, ok := rejectedSet[tc.Name]; !ok {
rejectedSet[tc.Name] = struct{}{}
rejected = append(rejected, tc.Name)
}
continue
}
tc.Name = matchedName
if tc.Input == nil {
tc.Input = map[string]any{}
}
out = append(out, tc)
}
return out, rejected
return out, nil
}
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
@@ -203,6 +196,18 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
return nil
}
func isLikelyJSONToolPayloadCandidate(candidate string) bool {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return false
}
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
return false
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"")
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
@@ -228,8 +233,10 @@ func isLikelyChatMessageEnvelope(v map[string]any) bool {
func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<function_name") ||
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
}

View File

@@ -16,6 +16,9 @@ var antmlParametersPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?parameter
var invokeCallPattern = regexp.MustCompile(`(?is)<invoke\s+name="([^"]+)"\s*>(.*?)</invoke>`)
var invokeParamPattern = regexp.MustCompile(`(?is)<parameter\s+name="([^"]+)"\s*>\s*(.*?)\s*</parameter>`)
var toolUseFunctionPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function\s+name="([^"]+)"\s*>(.*?)</function>\s*</tool_use>`)
var toolUseNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseFunctionNameParametersPattern = regexp.MustCompile(`(?is)<tool_use>\s*<function_name>\s*([^<]+?)\s*</function_name>\s*<parameters>\s*(.*?)\s*</parameters>\s*</tool_use>`)
var toolUseToolNameBodyPattern = regexp.MustCompile(`(?is)<tool_use>\s*<tool_name>\s*([^<]+?)\s*</tool_name>\s*(.*?)\s*</tool_use>`)
func parseXMLToolCalls(text string) []ParsedToolCall {
matches := xmlToolCallPattern.FindAllString(text, -1)
@@ -42,6 +45,15 @@ func parseXMLToolCalls(text string) []ParsedToolCall {
if call, ok := parseToolUseFunctionStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseFunctionNameParametersStyle(text); ok {
return []ParsedToolCall{call}
}
if call, ok := parseToolUseToolNameBodyStyle(text); ok {
return []ParsedToolCall{call}
}
return nil
}
@@ -92,6 +104,34 @@ func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) {
}
case "parameters":
inParams = true
var node struct {
Inner string `xml:",innerxml"`
}
if err := dec.DecodeElement(&node, &t); err == nil {
inner := strings.TrimSpace(node.Inner)
if inner != "" {
if parsed := parseToolCallInput(inner); len(parsed) > 0 {
if len(parsed) == 1 {
if _, onlyRaw := parsed["_raw"]; onlyRaw {
if kv := parseMarkupKVObject(inner); len(kv) > 0 {
for k, vv := range kv {
params[k] = vv
}
break
}
}
}
for k, vv := range parsed {
params[k] = vv
}
} else if kv := parseMarkupKVObject(inner); len(kv) > 0 {
for k, vv := range kv {
params[k] = vv
}
}
}
}
inParams = false
case "tool_name", "name":
var v string
if err := dec.DecodeElement(&v, &t); err == nil && strings.TrimSpace(v) != "" {
@@ -257,6 +297,104 @@ func parseToolUseFunctionStyle(text string) (ParsedToolCall, bool) {
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
input = parsed
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
input = kv
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseFunctionNameParametersStyle(text string) (ParsedToolCall, bool) {
m := toolUseFunctionNameParametersPattern.FindStringSubmatch(text)
if len(m) < 3 {
return ParsedToolCall{}, false
}
name := strings.TrimSpace(m[1])
if name == "" {
return ParsedToolCall{}, false
}
raw := strings.TrimSpace(m[2])
input := map[string]any{}
if raw != "" {
if parsed := parseToolCallInput(raw); len(parsed) > 0 {
input = parsed
} else if kv := parseMarkupKVObject(raw); len(kv) > 0 {
input = kv
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseToolUseToolNameBodyStyle(text string) (ParsedToolCall, bool) {
m := toolUseToolNameBodyPattern.FindStringSubmatch(text)
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 body != "" {
if kv := parseXMLChildKV(body); len(kv) > 0 {
input = kv
} else if kv := parseMarkupKVObject(body); len(kv) > 0 {
input = kv
} else if parsed := parseToolCallInput(body); len(parsed) > 0 {
input = parsed
}
}
return ParsedToolCall{Name: name, Input: input}, true
}
func parseXMLChildKV(body string) map[string]any {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil
}
dec := xml.NewDecoder(strings.NewReader("<root>" + trimmed + "</root>"))
out := map[string]any{}
for {
tok, err := dec.Token()
if err != nil {
break
}
start, ok := tok.(xml.StartElement)
if !ok || strings.EqualFold(start.Name.Local, "root") {
continue
}
var v string
if err := dec.DecodeElement(&v, &start); err != nil {
continue
}
key := strings.TrimSpace(start.Name.Local)
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
out[key] = val
}
if len(out) == 0 {
return nil
}
return out
}
func asString(v any) string {
s, _ := v.(string)
return s

View File

@@ -41,50 +41,50 @@ func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) {
}
}
func TestParseToolCallsRejectsUnknownToolName(t *testing.T) {
func TestParseToolCallsKeepsUnknownToolName(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 0 {
t.Fatalf("expected unknown tool to be rejected, got %#v", calls)
if len(calls) != 1 || calls[0].Name != "unknown" {
t.Fatalf("expected unknown tool to be preserved, got %#v", calls)
}
}
func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) {
func TestParseToolCallsKeepsOriginalToolNameCase(t *testing.T) {
text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}`
calls := ParseToolCalls(text, []string{"bash"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "bash" {
t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name)
if calls[0].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
}
func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) {
func TestParseToolCallsDetailedDoesNotRejectByPolicy(t *testing.T) {
text := `{"tool_calls":[{"name":"unknown","input":{}}]}`
res := ParseToolCallsDetailed(text, []string{"search"})
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if !res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 0 {
t.Fatalf("expected no calls after policy rejection, got %#v", res.Calls)
if len(res.Calls) != 1 || res.Calls[0].Name != "unknown" {
t.Fatalf("expected call to be preserved, got %#v", res.Calls)
}
}
func TestParseToolCallsDetailedRejectsWhenAllowListEmpty(t *testing.T) {
func TestParseToolCallsDetailedAllowsWhenAllowListEmpty(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
res := ParseToolCallsDetailed(text, nil)
if !res.SawToolCallSyntax {
t.Fatalf("expected SawToolCallSyntax=true, got %#v", res)
}
if !res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=true, got %#v", res)
if res.RejectedByPolicy {
t.Fatalf("expected RejectedByPolicy=false, got %#v", res)
}
if len(res.Calls) != 0 {
t.Fatalf("expected no calls when allow-list is empty, got %#v", res.Calls)
if len(res.Calls) != 1 || res.Calls[0].Name != "search" {
t.Fatalf("expected calls when allow-list is empty, got %#v", res.Calls)
}
}
@@ -132,8 +132,8 @@ func TestParseToolCallsAllowsQualifiedToolName(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected canonical tool name search_web, got %q", calls[0].Name)
if calls[0].Name != "mcp.search_web" {
t.Fatalf("expected original tool name mcp.search_web, got %q", calls[0].Name)
}
}
@@ -143,8 +143,8 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) {
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "read_file" {
t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name)
if calls[0].Name != "read-file" {
t.Fatalf("expected original tool name read-file, got %q", calls[0].Name)
}
}
@@ -154,14 +154,42 @@ func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original 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 TestParseToolCallsSupportsCanonicalXMLParametersJSON(t *testing.T) {
text := `<tool_call><tool_name>get_weather</tool_name><parameters>{"city":"beijing","unit":"c"}</parameters></tool_call>`
calls := ParseToolCalls(text, []string{"get_weather"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "get_weather" {
t.Fatalf("expected tool name get_weather, got %q", calls[0].Name)
}
if calls[0].Input["city"] != "beijing" || calls[0].Input["unit"] != "c" {
t.Fatalf("expected parsed json parameters, got %#v", calls[0].Input)
}
}
func TestParseToolCallsPrefersJSONPayloadOverIncidentalXMLInString(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"latest <tool_call><tool_name>wrong</tool_name><parameters>{\"x\":1}</parameters></tool_call>"}}]}`
calls := ParseToolCallsDetailed(text, []string{"search"}).Calls
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search" {
t.Fatalf("expected tool name search, got %q", calls[0].Name)
}
if calls[0].Input["q"] == nil {
t.Fatalf("expected q argument from json payload, 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"})
@@ -179,8 +207,8 @@ func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -193,8 +221,8 @@ func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original 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)
@@ -207,8 +235,8 @@ func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -221,8 +249,8 @@ func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -235,8 +263,8 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -257,14 +285,56 @@ func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
}
}
func TestParseToolCallsSupportsToolUseNameParametersStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><parameters>{"path":"/tmp/a.txt","content":"abc"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/a.txt" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionNameParametersStyle(t *testing.T) {
text := `<tool_use><function_name>write_file</function_name><parameters>{"path":"/tmp/b.txt","content":"xyz"}</parameters></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["content"] != "xyz" {
t.Fatalf("expected content argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseToolNameBodyStyle(t *testing.T) {
text := `<tool_use><tool_name>write_file</tool_name><path>/tmp/c.txt</path><content>hello</content></tool_use>`
calls := ParseToolCalls(text, []string{"write_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "write_file" {
t.Fatalf("expected tool name write_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "/tmp/c.txt" {
t.Fatalf("expected path 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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -277,8 +347,8 @@ func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testin
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].Name != "Bash" {
t.Fatalf("expected original tool name Bash, got %q", calls[0].Name)
}
if calls[0].Input["command"] != "pwd" {
t.Fatalf("expected command argument, got %#v", calls[0].Input)
@@ -291,8 +361,8 @@ func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) {
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)
if calls[0].Name != "Bash" || calls[1].Name != "Read" {
t.Fatalf("expected original names [Bash Read], got %#v", calls)
}
}

View File

@@ -6,14 +6,12 @@ import (
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...
`

View File

@@ -162,13 +162,16 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) {
{"role": "user", "content": "World"},
}
got := MessagesPrepare(messages)
if !strings.HasPrefix(got, "<User>") {
t.Fatalf("expected user marker at the start, got %q", got)
}
if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") {
t.Fatalf("expected both messages, got %q", got)
}
// Should be merged without <User> between them
// Should be merged into a single user turn with one marker at the start.
count := strings.Count(got, "<User>")
if count != 0 {
t.Fatalf("expected no User marker for first message pair, got %d occurrences", count)
if count != 1 {
t.Fatalf("expected one User marker for the merged pair, got %d occurrences", count)
}
}
@@ -364,8 +367,8 @@ func TestFormatOpenAIStreamToolCalls(t *testing.T) {
func TestParseToolCallsNoToolNames(t *testing.T) {
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
calls := ParseToolCalls(text, nil)
if len(calls) != 0 {
t.Fatalf("expected 0 call with nil tool names, got %d", len(calls))
if len(calls) != 1 {
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
}
}

View File

@@ -16,6 +16,8 @@ internal/js/helpers/stream-tool-sieve.js
internal/js/helpers/stream-tool-sieve/index.js
internal/js/helpers/stream-tool-sieve/state.js
internal/js/helpers/stream-tool-sieve/sieve.js
internal/js/helpers/stream-tool-sieve/sieve-xml.js
internal/js/helpers/stream-tool-sieve/jsonscan.js
internal/js/helpers/stream-tool-sieve/parse.js
internal/js/helpers/stream-tool-sieve/format.js
internal/js/helpers/stream-tool-sieve/tool-keywords.js

View File

@@ -53,6 +53,7 @@ internal/adapter/openai/responses_stream_runtime_events.go
internal/adapter/openai/responses_stream_runtime_toolcalls.go
internal/adapter/openai/tool_sieve_state.go
internal/adapter/openai/tool_sieve_core.go
internal/adapter/openai/tool_sieve_xml.go
internal/adapter/openai/tool_sieve_jsonscan.go
internal/util/toolcalls_parse.go
@@ -106,6 +107,7 @@ internal/js/helpers/stream-tool-sieve.js
internal/js/helpers/stream-tool-sieve/index.js
internal/js/helpers/stream-tool-sieve/state.js
internal/js/helpers/stream-tool-sieve/sieve.js
internal/js/helpers/stream-tool-sieve/sieve-xml.js
internal/js/helpers/stream-tool-sieve/jsonscan.js
internal/js/helpers/stream-tool-sieve/parse.js
internal/js/helpers/stream-tool-sieve/format.js

Binary file not shown.

View File

@@ -1,8 +1,13 @@
{
"calls": [],
"calls": [
{
"name": "unknown_tool",
"input": {
"x": 1
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": true,
"rejectedToolNames": [
"unknown_tool"
]
}
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

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

View File

@@ -0,0 +1,13 @@
{
"calls": [
{
"name": "search",
"input": {
"q": "latest <tool_call><tool_name>wrong</tool_name><parameters>{\"x\":1}</parameters></tool_call>"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -1,7 +1,7 @@
{
"calls": [
{
"name": "read_file",
"name": "read-file",
"input": {
"path": "README.MD"
}
@@ -10,4 +10,4 @@
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,7 +1,7 @@
{
"calls": [
{
"name": "read_file",
"name": "company.fs.read_file",
"input": {
"path": "README.MD"
}
@@ -10,4 +10,4 @@
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}
}

View File

@@ -1,8 +1,13 @@
{
"calls": [],
"calls": [
{
"name": "unknown_tool",
"input": {
"x": 1
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": true,
"rejectedToolNames": [
"unknown_tool"
]
}
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -0,0 +1,14 @@
{
"calls": [
{
"name": "get_weather",
"input": {
"city": "beijing",
"unit": "c"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false,
"rejectedToolNames": []
}

View File

@@ -0,0 +1,6 @@
{
"text": "{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"latest <tool_call><tool_name>wrong</tool_name><parameters>{\\\"x\\\":1}</parameters></tool_call>\"}}]}",
"tool_names": [
"search"
]
}

View File

@@ -0,0 +1,6 @@
{
"text": "<tool_call><tool_name>get_weather</tool_name><parameters>{\"city\":\"beijing\",\"unit\":\"c\"}</parameters></tool_call>",
"tool_names": [
"get_weather"
]
}

View File

@@ -34,7 +34,7 @@ test('resolveToolcallPolicy defaults to feature-match + early emit when prepare
assert.equal(policy.emitEarlyToolDeltas, true);
});
test('resolveToolcallPolicy respects prepare flags and prepared tool names', () => {
test('resolveToolcallPolicy ignores prepare flags and keeps early emit enabled', () => {
const policy = resolveToolcallPolicy(
{
tool_names: [' prepped_tool ', '', null],
@@ -45,7 +45,7 @@ test('resolveToolcallPolicy respects prepare flags and prepared tool names', ()
);
assert.deepEqual(policy.toolNames, ['prepped_tool']);
assert.equal(policy.toolSieveEnabled, true);
assert.equal(policy.emitEarlyToolDeltas, false);
assert.equal(policy.emitEarlyToolDeltas, true);
});
test('normalizePreparedToolNames filters empty values', () => {
@@ -58,7 +58,7 @@ test('boolDefaultTrue keeps false only when explicitly false', () => {
assert.equal(boolDefaultTrue(undefined), true);
});
test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up args', () => {
test('filterIncrementalToolCallDeltasByAllowed keeps unknown name and follow-up args', () => {
const seen = new Map();
const filtered = filterIncrementalToolCallDeltasByAllowed(
[
@@ -68,8 +68,11 @@ test('filterIncrementalToolCallDeltasByAllowed blocks unknown name and follow-up
['read_file'],
seen,
);
assert.deepEqual(filtered, []);
assert.equal(seen.get(0), '__blocked__');
assert.deepEqual(filtered, [
{ index: 0, name: 'not_in_schema' },
{ index: 0, arguments: '{"x":1}' },
]);
assert.equal(seen.get(0), 'not_in_schema');
});
test('filterIncrementalToolCallDeltasByAllowed keeps allowed name and args', () => {

View File

@@ -55,33 +55,34 @@ test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', ()
]);
});
test('parseToolCalls drops unknown schema names when toolNames is provided', () => {
test('parseToolCalls keeps unknown schema names when toolNames is provided', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
});
const calls = parseToolCalls(payload, ['search']);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'not_in_schema');
});
test('parseToolCalls matches tool name case-insensitively and canonicalizes', () => {
test('parseToolCalls keeps original tool name casing', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'Read_File', input: { path: 'README.MD' } }],
});
const calls = parseToolCalls(payload, ['read_file']);
assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.MD' } }]);
assert.deepEqual(calls, [{ name: 'Read_File', input: { path: 'README.MD' } }]);
});
test('parseToolCalls rejects all names when toolNames is empty (Go strict parity)', () => {
test('parseToolCalls accepts all names when toolNames is empty', () => {
const payload = JSON.stringify({
tool_calls: [{ name: 'not_in_schema', input: { q: 'go' } }],
});
const calls = parseToolCalls(payload, []);
assert.equal(calls.length, 0);
assert.equal(calls.length, 1);
const detailed = parseToolCallsDetailed(payload, []);
assert.equal(detailed.sawToolCallSyntax, true);
assert.equal(detailed.rejectedByPolicy, true);
assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']);
assert.equal(detailed.rejectedByPolicy, false);
assert.deepEqual(detailed.rejectedToolNames, []);
});
test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => {
@@ -97,10 +98,8 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co
test('parseToolCalls parses text-kv fallback payload', () => {
const text = [
'[TOOL_CALL_HISTORY]',
'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...',
].join('\n');
const calls = parseToolCalls(text, ['execute_command']);
@@ -212,6 +211,40 @@ test('sieve flushes incomplete captured tool json as text on stream finalize', (
assert.equal(leakedText.includes('{'), true);
});
test('sieve flushes incomplete captured XML tool blocks without leaking raw tags', () => {
const events = runSieve(
[
'前置正文G。',
'<tool_calls>\n',
' <tool_call>\n',
' <tool_name>read_file</tool_name>\n',
],
['read_file'],
);
const leakedText = collectText(events);
assert.equal(leakedText.includes('前置正文G。'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
assert.equal(leakedText.includes('<tool_call'), false);
});
test('sieve captures XML wrapper tags with attributes without leaking wrapper text', () => {
const events = runSieve(
[
'前置正文H。',
'<tool_calls id="x"><tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call></tool_calls>',
'后置正文I。',
],
['read_file'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, true);
assert.equal(leakedText.includes('前置正文H。'), true);
assert.equal(leakedText.includes('后置正文I。'), true);
assert.equal(leakedText.includes('<tool_calls id=\"x\">'), false);
assert.equal(leakedText.includes('</tool_calls>'), false);
});
test('sieve still intercepts large tool json payloads over previous capture limit', () => {
const large = 'a'.repeat(9000);
const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`;
@@ -237,57 +270,47 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
});
test('sieve swallows leaked TOOL_CALL_HISTORY marker blocks', () => {
test('sieve keeps plain "tool_calls" prose as text when no valid payload follows', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false);
});
test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false);
});
test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => {
const events = runSieve(
[
'Hello ',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\n',
'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'world',
],
['exec'],
['前置。', '这里提到 tool_calls 只是解释,不是调用。', '后置。'],
['read_file'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, false);
assert.equal(leakedText, 'Hello world');
assert.equal(leakedText.includes('tool_calls'), true);
assert.equal(leakedText, '前置。这里提到 tool_calls 只是解释,不是调用。后置。');
});
test('sieve intercepts rejected unknown tool payload (no args) without raw leak', () => {
test('sieve keeps numbered planning prose before a real tool payload (mobile-chat style)', () => {
const events = runSieve(
[
'好的,我会依次测试每个工具,先把所有工具都调用一遍,然后汇总结果给你看。\n\n1. 获取当前时间\n',
'{"tool_calls":[{"name":"get_current_time","input":{}}]}',
],
['get_current_time'],
);
const leakedText = collectText(events);
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
assert.equal(finalCalls.length, 1);
assert.equal(finalCalls[0].name, 'get_current_time');
assert.equal(leakedText.includes('先把所有工具都调用一遍'), true);
assert.equal(leakedText.includes('1. 获取当前时间'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
});
test('sieve keeps numbered planning prose when no tool payload follows', () => {
const events = runSieve(
['好的,我会依次测试每个工具。\n\n1. 获取当前时间'],
['get_current_time'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, false);
assert.equal(leakedText, '好的,我会依次测试每个工具。\n\n1. 获取当前时间');
});
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
const events = runSieve(
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
['read_file'],
@@ -295,8 +318,7 @@ test('sieve intercepts rejected unknown tool payload (no args) without raw leak'
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0);
const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0);
assert.equal(hasToolCall, false);
assert.equal(hasToolDelta, false);
assert.equal(hasToolCall || hasToolDelta, true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
assert.equal(leakedText.includes('后置正文G。'), true);
});

View File

@@ -4,7 +4,7 @@
"outputDirectory": "static",
"functions": {
"api/chat-stream.js": {
"includeFiles": "**/sha3_wasm_bg.7b9ca65ddd.wasm",
"includeFiles": "internal/deepseek/assets/sha3_wasm_bg.7b9ca65ddd.wasm",
"maxDuration": 300
},
"api/index.go": {

View File

@@ -3,35 +3,6 @@ export default function BehaviorSection({ t, form, setForm }) {
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.behaviorTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.toolcallMode')}</span>
<select
value={form.toolcall.mode}
onChange={(e) => setForm((prev) => ({
...prev,
toolcall: { ...prev.toolcall, mode: e.target.value },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
>
<option value="feature_match">feature_match</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.earlyEmitConfidence')}</span>
<select
value={form.toolcall.early_emit_confidence}
onChange={(e) => setForm((prev) => ({
...prev,
toolcall: { ...prev.toolcall, early_emit_confidence: e.target.value },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
>
<option value="high">high</option>
<option value="low">low</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.responsesTTL')}</span>
<input

View File

@@ -2,7 +2,7 @@ export default function RuntimeSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
<input
@@ -42,6 +42,21 @@ export default function RuntimeSection({ t, form, setForm }) {
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.tokenRefreshIntervalHours')}</span>
<input
type="number"
min={1}
max={720}
step={1}
value={form.runtime.token_refresh_interval_hours}
onChange={(e) => setForm((prev) => ({
...prev,
runtime: { ...prev.runtime, token_refresh_interval_hours: Number(e.target.value || 1) },
}))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
</div>
</div>
)

View File

@@ -12,8 +12,7 @@ const MAX_AUTO_FETCH_FAILURES = 3
const DEFAULT_FORM = {
admin: { jwt_expire_hours: 24 },
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10 },
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10, token_refresh_interval_hours: 6 },
responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' },
auto_delete: { sessions: false },
@@ -45,10 +44,7 @@ function fromServerForm(data) {
account_max_inflight: Number(data.runtime?.account_max_inflight || 2),
account_max_queue: Number(data.runtime?.account_max_queue || 10),
global_max_inflight: Number(data.runtime?.global_max_inflight || 10),
},
toolcall: {
mode: data.toolcall?.mode || 'feature_match',
early_emit_confidence: data.toolcall?.early_emit_confidence || 'high',
token_refresh_interval_hours: Number(data.runtime?.token_refresh_interval_hours || 6),
},
responses: {
store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900),
@@ -71,10 +67,7 @@ function toServerPayload(form) {
account_max_inflight: Number(form.runtime.account_max_inflight),
account_max_queue: Number(form.runtime.account_max_queue),
global_max_inflight: Number(form.runtime.global_max_inflight),
},
toolcall: {
mode: String(form.toolcall.mode || '').trim(),
early_emit_confidence: String(form.toolcall.early_emit_confidence || '').trim(),
token_refresh_interval_hours: Number(form.runtime.token_refresh_interval_hours),
},
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() },

View File

@@ -222,13 +222,12 @@
"passwordTooShort": "Password must be at least 4 characters.",
"passwordUpdated": "Password updated. Please sign in again.",
"passwordUpdateFailed": "Failed to update password.",
"runtimeTitle": "Concurrency & Queue",
"runtimeTitle": "Runtime",
"accountMaxInflight": "Per-account max inflight",
"accountMaxQueue": "Account max queue size",
"globalMaxInflight": "Global max inflight",
"tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
"behaviorTitle": "Behavior",
"toolcallMode": "Toolcall mode",
"earlyEmitConfidence": "Early emit confidence",
"responsesTTL": "Responses store TTL (seconds)",
"embeddingsProvider": "Embeddings provider",
"modelTitle": "Model mapping",

View File

@@ -222,13 +222,12 @@
"passwordTooShort": "新密码至少 4 位",
"passwordUpdated": "密码已更新,需重新登录",
"passwordUpdateFailed": "密码更新失败",
"runtimeTitle": "并发与队列",
"runtimeTitle": "运行时设置",
"accountMaxInflight": "每账号并发上限",
"accountMaxQueue": "账号等待队列上限",
"globalMaxInflight": "全局并发上限",
"tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
"behaviorTitle": "行为设置",
"toolcallMode": "Toolcall 模式",
"earlyEmitConfidence": "早发置信度",
"responsesTTL": "Responses 缓存 TTL",
"embeddingsProvider": "Embeddings Provider",
"modelTitle": "模型映射",