Compare commits

..

28 Commits

Author SHA1 Message Date
CJACK.
063599678a Merge pull request #174 from CJackHwang/dev
Merge pull request #171 from CJackHwang/codex/fix-issue-#170-in-ds2api

Enable env-backed config writeback and bootstrap missing config file
2026-03-31 01:36:31 +08:00
CJACK.
f55aa7564a Merge pull request #176 from CJackHwang/codex/fix-fallback-to-file-config-on-json-parse-failure-u32uz8
修复写回模式回退逻辑并从 CONTENT_FILTER 起截断流输出
2026-03-31 01:35:42 +08:00
CJACK.
3b60e3c8f9 fix(sse): trim stream output from CONTENT_FILTER onward 2026-03-31 01:26:43 +08:00
CJACK.
efebe9ebad Merge pull request #171 from CJackHwang/codex/fix-issue-#170-in-ds2api
Enable env-backed config writeback and bootstrap missing config file
2026-03-31 00:01:27 +08:00
CJACK.
b54b418f96 fix(sse): globally strip leaked CONTENT_FILTER suffix from output 2026-03-30 23:39:47 +08:00
CJACK.
1c5f022b06 refactor(config): split writeback helpers out of store.go for CI gate 2026-03-30 21:38:19 +08:00
CJACK.
836eaf5290 feat(ui): show env mode persistence status and document writeback 2026-03-30 21:18:56 +08:00
CJACK.
958e7a0d04 fix(config): skip writeback bootstrap on invalid env config 2026-03-30 21:02:36 +08:00
CJACK.
f3555ae9b0 feat(config): bootstrap config.json when env writeback is enabled 2026-03-30 20:37:58 +08:00
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
72 changed files with 1101 additions and 621 deletions

View File

@@ -79,7 +79,7 @@ jobs:
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ 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 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" cp -R static/admin "${STAGE}/static/admin"
if [ "${GOOS}" = "windows" ]; then if [ "${GOOS}" = "windows" ]; then

View File

@@ -587,6 +587,9 @@ Returns sanitized config.
{ {
"keys": ["k1", "k2"], "keys": ["k1", "k2"],
"env_backed": false, "env_backed": false,
"env_source_present": true,
"env_writeback_enabled": true,
"config_path": "/data/config.json",
"accounts": [ "accounts": [
{ {
"identifier": "user@example.com", "identifier": "user@example.com",
@@ -629,24 +632,25 @@ Reads runtime settings and status, including:
- `success` - `success`
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`) - `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`) - `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
- `toolcall` / `responses` / `embeddings` - `responses` / `embeddings`
- `auto_delete` (`sessions`) - `auto_delete` (`sessions`)
- `claude_mapping` / `model_aliases` - `claude_mapping` / `model_aliases`
- `env_backed`, `needs_vercel_sync` - `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` ### `PUT /admin/settings`
Hot-updates runtime settings. Supported fields: Hot-updates runtime settings. Supported fields:
- `admin.jwt_expire_hours` - `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds` - `responses.store_ttl_seconds`
- `embeddings.provider` - `embeddings.provider`
- `auto_delete.sessions` - `auto_delete.sessions`
- `claude_mapping` - `claude_mapping`
- `model_aliases` - `model_aliases`
- `toolcall` policy is fixed and is no longer writable through settings
### `POST /admin/settings/password` ### `POST /admin/settings/password`
@@ -669,7 +673,7 @@ Imports full config with:
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
Query params `?mode=merge` / `?mode=replace` are also supported. Query params `?mode=merge` / `?mode=replace` are also supported.
Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `toolcall`, `responses`, `embeddings`, and `auto_delete`. 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` ### `GET /admin/config/export`

14
API.md
View File

@@ -596,6 +596,9 @@ data: {"type":"message_stop"}
{ {
"keys": ["k1", "k2"], "keys": ["k1", "k2"],
"env_backed": false, "env_backed": false,
"env_source_present": true,
"env_writeback_enabled": true,
"config_path": "/data/config.json",
"accounts": [ "accounts": [
{ {
"identifier": "user@example.com", "identifier": "user@example.com",
@@ -638,24 +641,25 @@ data: {"type":"message_stop"}
- `success` - `success`
- `admin``has_password_hash``jwt_expire_hours``jwt_valid_after_unix``default_password_warning` - `admin``has_password_hash``jwt_expire_hours``jwt_valid_after_unix``default_password_warning`
- `runtime``account_max_inflight``account_max_queue``global_max_inflight` - `runtime``account_max_inflight``account_max_queue``global_max_inflight``token_refresh_interval_hours`
- `toolcall` / `responses` / `embeddings` - `responses` / `embeddings`
- `auto_delete``sessions` - `auto_delete``sessions`
- `claude_mapping` / `model_aliases` - `claude_mapping` / `model_aliases`
- `env_backed``needs_vercel_sync` - `env_backed``needs_vercel_sync`
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
### `PUT /admin/settings` ### `PUT /admin/settings`
热更新运行时设置。支持更新: 热更新运行时设置。支持更新:
- `admin.jwt_expire_hours` - `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` - `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `toolcall.mode` / `toolcall.early_emit_confidence`
- `responses.store_ttl_seconds` - `responses.store_ttl_seconds`
- `embeddings.provider` - `embeddings.provider`
- `auto_delete.sessions` - `auto_delete.sessions`
- `claude_mapping` - `claude_mapping`
- `model_aliases` - `model_aliases`
- `toolcall` 策略已固定,不再作为可写入字段
### `POST /admin/settings/password` ### `POST /admin/settings/password`
@@ -678,7 +682,7 @@ data: {"type":"message_stop"}
请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。
也支持在查询参数里传 `?mode=merge` / `?mode=replace` 也支持在查询参数里传 `?mode=merge` / `?mode=replace`
导入时会接受 `keys``accounts``claude_mapping` / `claude_model_mapping``model_aliases``admin``runtime``toolcall``responses``embeddings``auto_delete` 等字段。 导入时会接受 `keys``accounts``claude_mapping` / `claude_model_mapping``model_aliases``admin``runtime``responses``embeddings``auto_delete` 等字段`toolcall` 相关字段会被忽略
### `GET /admin/config/export` ### `GET /admin/config/export`

View File

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

View File

@@ -8,7 +8,7 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) [![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) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
@@ -213,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 对齐的输出组装与防泄漏处理。 > **流式说明**`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`Node Runtime以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。
详细部署说明请参阅 [部署指南](DEPLOY.md)。 详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。
### 方式四:下载 Release 构建包 ### 方式四:下载 Release 构建包
@@ -270,10 +270,6 @@ cp opencode.json.example opencode.json
"compat": { "compat": {
"wide_input_strict_output": true "wide_input_strict_output": true
}, },
"toolcall": {
"mode": "feature_match",
"early_emit_confidence": "high"
},
"responses": { "responses": {
"store_ttl_seconds": 900 "store_ttl_seconds": 900
}, },
@@ -290,7 +286,8 @@ cp opencode.json.example opencode.json
"runtime": { "runtime": {
"account_max_inflight": 2, "account_max_inflight": 2,
"account_max_queue": 0, "account_max_queue": 0,
"global_max_inflight": 0 "global_max_inflight": 0,
"token_refresh_interval_hours": 6
}, },
"auto_delete": { "auto_delete": {
"sessions": false "sessions": false
@@ -303,12 +300,12 @@ cp opencode.json.example opencode.json
- `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新 - `token`:配置文件中即使填写也会在加载时被清空(不会从 `config.json` 读取 token实际 token 仅在运行时内存中维护并自动刷新
- `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射 - `model_aliases`:常见模型名(如 GPT/Codex/Claude到 DeepSeek 模型的映射
- `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出) - `compat.wide_input_strict_output`:建议保持 `true`(当前实现默认宽进严出)
- `toolcall`固定采用特征匹配 + 高置信早发策略 - `toolcall`策略已固定为特征匹配 + 高置信早发,不再作为可配置项
- `responses.store_ttl_seconds``/v1/responses/{id}` 的内存缓存 TTL - `responses.store_ttl_seconds``/v1/responses/{id}` 的内存缓存 TTL
- `embeddings.provider`embedding 提供方(当前内置 `deterministic/mock/builtin` - `embeddings.provider`embedding 提供方(当前内置 `deterministic/mock/builtin`
- `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping` - `claude_mapping`:字典中 `fast`/`slow` 后缀映射到对应 DeepSeek 模型(兼容读取 `claude_model_mapping`
- `admin`管理后台设置JWT 过期时间、密码哈希等),可通过 Admin Settings API 热更新 - `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 热更新) - `auto_delete.sessions`:是否在请求结束后自动清理 DeepSeek 会话(默认 `false`,可在 Settings 热更新)
### 环境变量 ### 环境变量
@@ -323,6 +320,7 @@ cp opencode.json.example opencode.json
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` | | `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
| `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — | | `DS2API_CONFIG_JSON` | 直接注入配置JSON 或 Base64 | — |
| `CONFIG_JSON` | 旧版兼容配置注入 | — | | `CONFIG_JSON` | 旧版兼容配置注入 | — |
| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on` | 关闭 |
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 | | `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` | | `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启Vercel 关闭 | | `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启Vercel 关闭 |
@@ -345,6 +343,8 @@ cp opencode.json.example opencode.json
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — | | `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — |
> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
## 鉴权模式 ## 鉴权模式
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式: 调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
@@ -450,6 +450,7 @@ ds2api/
├── tests/ ├── tests/
│ ├── compat/ # 兼容性测试夹具与期望输出 │ ├── compat/ # 兼容性测试夹具与期望输出
│ └── scripts/ # 统一测试脚本入口unit/e2e │ └── scripts/ # 统一测试脚本入口unit/e2e
├── docs/ # 部署 / 贡献 / 测试等辅助文档
├── static/admin/ # WebUI 构建产物(不提交到 Git ├── static/admin/ # WebUI 构建产物(不提交到 Git
├── .github/ ├── .github/
│ ├── workflows/ # GitHub Actions质量门禁 + Release 自动构建) │ ├── workflows/ # GitHub Actions质量门禁 + Release 自动构建)
@@ -469,9 +470,9 @@ ds2api/
| 文档 | 说明 | | 文档 | 说明 |
| --- | --- | | --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API 接口文档(含请求/响应示例) | | [API.md](API.md) / [API.en.md](API.en.md) | API 接口文档(含请求/响应示例) |
| [DEPLOY.md](DEPLOY.md) / [DEPLOY.en.md](DEPLOY.en.md) | 部署指南(本地/Docker/Vercel/systemd | | [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | 部署指南(本地/Docker/Vercel/systemd |
| [CONTRIBUTING.md](CONTRIBUTING.md) / [CONTRIBUTING.en.md](CONTRIBUTING.en.md) | 贡献指南 | | [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | 贡献指南 |
| [TESTING.md](TESTING.md) | 测试集使用指南 | | [TESTING.md](docs/TESTING.md) | 测试集使用指南 |
## 测试 ## 测试
@@ -501,7 +502,7 @@ npm ci --prefix webui && npm run build --prefix webui
## 测试 ## 测试
详细测试指南请参阅 [TESTING.md](TESTING.md)。 详细测试指南请参阅 [docs/TESTING.md](docs/TESTING.md)。
### 快速测试命令 ### 快速测试命令

View File

@@ -8,7 +8,7 @@
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.en.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) [![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) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
@@ -213,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. > **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 ### Option 4: Download Release Binaries
@@ -270,10 +270,6 @@ cp opencode.json.example opencode.json
"compat": { "compat": {
"wide_input_strict_output": true "wide_input_strict_output": true
}, },
"toolcall": {
"mode": "feature_match",
"early_emit_confidence": "high"
},
"responses": { "responses": {
"store_ttl_seconds": 900 "store_ttl_seconds": 900
}, },
@@ -290,7 +286,8 @@ cp opencode.json.example opencode.json
"runtime": { "runtime": {
"account_max_inflight": 2, "account_max_inflight": 2,
"account_max_queue": 0, "account_max_queue": 0,
"global_max_inflight": 0 "global_max_inflight": 0,
"token_refresh_interval_hours": 6
}, },
"auto_delete": { "auto_delete": {
"sessions": false "sessions": false
@@ -303,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 - `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 - `model_aliases`: Map common model names (GPT/Codex/Claude) to DeepSeek models
- `compat.wide_input_strict_output`: Keep `true` (current default policy) - `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}` - `responses.store_ttl_seconds`: In-memory TTL for `/v1/responses/{id}`
- `embeddings.provider`: Embeddings provider (`deterministic/mock/builtin` built-in) - `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`) - `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 - `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) - `auto_delete.sessions`: Whether to auto-delete DeepSeek sessions after request completion (default `false`, hot-reloadable via Settings)
### Environment Variables ### Environment Variables
@@ -323,6 +320,7 @@ cp opencode.json.example opencode.json
| `DS2API_CONFIG_PATH` | Config file path | `config.json` | | `DS2API_CONFIG_PATH` | Config file path | `config.json` |
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — | | `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
| `CONFIG_JSON` | Legacy compatibility config input | — | | `CONFIG_JSON` | Legacy compatibility config input | — |
| `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled |
| `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect | | `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect |
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` | | `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel | | `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
@@ -342,6 +340,8 @@ cp opencode.json.example opencode.json
| `VERCEL_TEAM_ID` | Vercel team ID | — | | `VERCEL_TEAM_ID` | Vercel team ID | — |
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — | | `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — |
> Note: when `DS2API_CONFIG_JSON/CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints).
## Authentication Modes ## Authentication Modes
For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes: For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes:
@@ -444,6 +444,7 @@ ds2api/
├── tests/ ├── tests/
│ ├── compat/ # Compatibility fixtures and expected outputs │ ├── compat/ # Compatibility fixtures and expected outputs
│ └── scripts/ # Unified test script entrypoints (unit/e2e) │ └── scripts/ # Unified test script entrypoints (unit/e2e)
├── docs/ # Deployment / contributing / testing docs
├── static/admin/ # WebUI build output (not committed to Git) ├── static/admin/ # WebUI build output (not committed to Git)
├── .github/ ├── .github/
│ ├── workflows/ # GitHub Actions (quality gates + release automation) │ ├── workflows/ # GitHub Actions (quality gates + release automation)
@@ -463,9 +464,9 @@ ds2api/
| Document | Description | | Document | Description |
| --- | --- | | --- | --- |
| [API.md](API.md) / [API.en.md](API.en.md) | API reference with request/response examples | | [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) | | [DEPLOY.md](docs/DEPLOY.md) / [DEPLOY.en.md](docs/DEPLOY.en.md) | Deployment guide (local/Docker/Vercel/systemd) |
| [CONTRIBUTING.md](CONTRIBUTING.md) / [CONTRIBUTING.en.md](CONTRIBUTING.en.md) | Contributing guide | | [CONTRIBUTING.md](docs/CONTRIBUTING.md) / [CONTRIBUTING.en.md](docs/CONTRIBUTING.en.md) | Contributing guide |
| [TESTING.md](TESTING.md) | Testsuite guide | | [TESTING.md](docs/TESTING.md) | Testsuite guide |
## Testing ## Testing

View File

@@ -1 +1 @@
2.4.1 2.5.1

View File

@@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — | | `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` | | `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — | | `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — |
| `DS2API_ENV_WRITEBACK` | When `DS2API_CONFIG_JSON` is present, auto-write to `DS2API_CONFIG_PATH` and switch to file-backed mode after success (`1/true/yes/on`) | Disabled |
| `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` |
| `VERCEL_TOKEN` | Vercel sync token | — | | `VERCEL_TOKEN` | Vercel sync token | — |
@@ -456,8 +457,8 @@ server {
# Copy compiled binary and related files to target directory # Copy compiled binary and related files to target directory
sudo mkdir -p /opt/ds2api sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json /opt/ds2api/ sudo cp ds2api config.json /opt/ds2api/
# Optional: if you want to use an external WASM file (override embedded one) # Optional: if you want to use an external WASM file (override the embedded one, from a release package or build output)
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/ # sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin sudo cp -r static/admin /opt/ds2api/static/admin
``` ```

View File

@@ -248,6 +248,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — | | `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — |
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` | | `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
| `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — | | `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — |
| `DS2API_ENV_WRITEBACK` | 检测到 `DS2API_CONFIG_JSON` 时自动写入 `DS2API_CONFIG_PATH`,并在成功后转为文件模式(`1/true/yes/on` | 关闭 |
| `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` | | `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` |
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` | | `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` |
| `VERCEL_TOKEN` | Vercel 同步 token | — | | `VERCEL_TOKEN` | Vercel 同步 token | — |
@@ -456,8 +457,8 @@ server {
# 将编译好的二进制文件和相关文件复制到目标目录 # 将编译好的二进制文件和相关文件复制到目标目录
sudo mkdir -p /opt/ds2api sudo mkdir -p /opt/ds2api
sudo cp ds2api config.json /opt/ds2api/ sudo cp ds2api config.json /opt/ds2api/
# 可选:若你希望使用外置 WASM 文件(覆盖内置版本) # 可选:若你希望使用外置 WASM 文件(覆盖内置版本,来自 release 包或构建产物
# sudo cp sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/ # sudo cp /path/to/sha3_wasm_bg.7b9ca65ddd.wasm /opt/ds2api/
sudo cp -r static/admin /opt/ds2api/static/admin sudo cp -r static/admin /opt/ds2api/static/admin
``` ```

View File

@@ -93,8 +93,11 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
t.Fatalf("expected call id preserved, got %#v", call) t.Fatalf("expected call id preserved, got %#v", call)
} }
content, _ := m["content"].(string) content, _ := m["content"].(string)
if !containsStr(content, "search_web") || !containsStr(content, `"arguments":"{\"query\":\"latest\"}"`) { if !containsStr(content, "<tool_calls>") || !containsStr(content, "<tool_name>search_web</tool_name>") {
t.Fatalf("expected assistant content to include serialized tool call for prompt roundtrip, got %q", content) 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)
} }
} }
@@ -251,9 +254,6 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "<tool_calls>") { if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt") 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, "TOOL CALL FORMAT") { if !containsStr(prompt, "TOOL CALL FORMAT") {
t.Fatalf("expected tool call format header in prompt") t.Fatalf("expected tool call format header in prompt")
} }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"ds2api/internal/prompt"
"ds2api/internal/util" "ds2api/internal/util"
) )
@@ -153,7 +154,7 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
} }
return map[string]any{ return map[string]any{
"role": "assistant", "role": "assistant",
"content": marshalCompactJSON(toolCalls), "content": prompt.FormatToolCallsForPrompt(toolCalls),
"tool_calls": toolCalls, "tool_calls": toolCalls,
} }
} }

View File

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

View File

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

View File

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

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,7 +1,6 @@
package openai package openai
import ( import (
"encoding/json"
"strings" "strings"
"ds2api/internal/prompt" "ds2api/internal/prompt"
@@ -55,7 +54,18 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an
} }
func buildAssistantContentForPrompt(msg map[string]any) string { func buildAssistantContentForPrompt(msg map[string]any) string {
return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) 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
}
} }
func buildToolContentForPrompt(msg map[string]any) string { func buildToolContentForPrompt(msg map[string]any) string {
@@ -70,18 +80,6 @@ func normalizeOpenAIContentForPrompt(v any) string {
return prompt.NormalizeContent(v) 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 { func normalizeOpenAIRoleForPrompt(role string) string {
role = strings.ToLower(strings.TrimSpace(role)) role = strings.ToLower(strings.TrimSpace(role))
if role == "developer" { if role == "developer" {
@@ -96,20 +94,3 @@ func asString(v any) string {
} }
return "" 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

@@ -34,20 +34,23 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
} }
normalized := normalizeOpenAIMessagesForPrompt(raw, "") normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 3 { if len(normalized) != 4 {
t.Fatalf("expected 3 normalized messages with tool-call-only assistant turn omitted, got %d", len(normalized)) t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
} }
toolContent, _ := normalized[2]["content"].(string) assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(toolContent, `"temp":18`) { if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("tool result should be transparently forwarded, got %q", toolContent) t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
} }
if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") { if !strings.Contains(assistantContent, "<tool_name>get_weather</tool_name>") {
t.Fatalf("tool history marker should not be injected: %q", toolContent) 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) prompt := util.MessagesPrepare(normalized)
if strings.Contains(prompt, "[TOOL_CALL_HISTORY]") || strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") { if !strings.Contains(prompt, "<tool_calls>") {
t.Fatalf("expected no synthetic history markers in prompt: %q", prompt) t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
} }
} }
@@ -170,8 +173,15 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
} }
normalized := normalizeOpenAIMessagesForPrompt(raw, "") normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 { if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only message omitted, got %#v", normalized) t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
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)
} }
} }
@@ -192,8 +202,12 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t *
} }
normalized := normalizeOpenAIMessagesForPrompt(raw, "") normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 { if len(normalized) != 1 {
t.Fatalf("expected assistant tool_call-only content omitted, got %#v", normalized) t.Fatalf("expected assistant tool_call-only content preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if !strings.Contains(content, `{}{"query":"测试工具调用"}`) {
t.Fatalf("expected concatenated tool arguments preserved, got %q", content)
} }
} }
@@ -215,7 +229,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe
normalized := normalizeOpenAIMessagesForPrompt(raw, "") normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 { if len(normalized) != 0 {
t.Fatalf("expected assistant tool_calls without text omitted, got %#v", normalized) t.Fatalf("expected assistant tool_calls without text to be dropped when name is missing, got %#v", normalized)
} }
} }
@@ -237,8 +251,15 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
} }
normalized := normalizeOpenAIMessagesForPrompt(raw, "") normalized := normalizeOpenAIMessagesForPrompt(raw, "")
if len(normalized) != 0 { if len(normalized) != 1 {
t.Fatalf("expected nil-content assistant tool_call-only message omitted, got %#v", normalized) t.Fatalf("expected nil-content assistant tool_call-only message preserved, got %#v", normalized)
}
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"`) { if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt) t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
} }
if strings.Contains(finalPrompt, "[TOOL_CALL_HISTORY]") || strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") { if !strings.Contains(finalPrompt, "<tool_calls>") {
t.Fatalf("handler finalPrompt should not include synthetic history markers: %q", finalPrompt) 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)
} }
} }

View File

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

View File

@@ -1,11 +1,11 @@
package openai package openai
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"ds2api/internal/config" "ds2api/internal/config"
"ds2api/internal/prompt"
) )
func normalizeResponsesInputItem(m map[string]any) map[string]any { 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{ functionPayload := map[string]any{
"name": name, "name": name,
"arguments": stringifyToolCallArguments(argsRaw), "arguments": prompt.StringifyToolCallArguments(argsRaw),
} }
call := map[string]any{ call := map[string]any{
"type": "function", "type": "function",
@@ -211,26 +211,3 @@ func normalizeResponsesFallbackPart(m map[string]any) string {
} }
return strings.TrimSpace(fmt.Sprintf("%v", m)) 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() { func (s *responsesStreamRuntime) finalize() {
finalThinking := s.thinking.String() finalThinking := s.thinking.String()
finalText := sanitizeLeakedToolHistory(s.text.String()) finalText := sanitizeLeakedOutput(s.text.String())
if s.bufferToolContent { if s.bufferToolContent {
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true) s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
@@ -194,7 +194,7 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
continue continue
} }
cleanedText := sanitizeLeakedToolHistory(p.Text) cleanedText := sanitizeLeakedOutput(p.Text)
if cleanedText == "" { if cleanedText == "" {
continue continue
} }

View File

@@ -1,32 +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*```")
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*[\|]>`)
// leakedAgentXMLPattern catches agent-style XML tags that leak through when
// the sieve fails to capture them (e.g. incomplete blocks at stream end).
var leakedAgentXMLPattern = regexp.MustCompile(`(?is)</?(?:attempt_completion|ask_followup_question|new_task|result)>`)
func sanitizeLeakedToolHistory(text string) string {
if text == "" {
return text
}
out := leakedToolHistoryPattern.ReplaceAllString(text, "")
out = emptyJSONFencePattern.ReplaceAllString(out, "")
out = leakedToolCallArrayPattern.ReplaceAllString(out, "")
out = leakedToolResultBlobPattern.ReplaceAllString(out, "")
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
out = leakedAgentXMLPattern.ReplaceAllString(out, "")
return out
}

View File

@@ -1,130 +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 TestSanitizeLeakedToolHistoryRemovesLeakedWireToolCallAndResult(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 := sanitizeLeakedToolHistory(raw)
if got != "开始\n\n结束" {
t.Fatalf("unexpected sanitize result for leaked wire format: %q", got)
}
}
func TestSanitizeLeakedToolHistoryRemovesStandaloneMetaMarkers(t *testing.T) {
raw := "A<| end_of_sentence |><| Assistant |>B<| end_of_thinking |>C<end▁of▁thinking>D<end▁of▁sentence>E"
got := sanitizeLeakedToolHistory(raw)
if got != "ABCDE" {
t.Fatalf("unexpected sanitize result for meta markers: %q", got)
}
}
func TestSanitizeLeakedToolHistoryRemovesAgentXMLLeaks(t *testing.T) {
raw := "Done.<attempt_completion><result>Some final answer</result></attempt_completion>"
got := sanitizeLeakedToolHistory(raw)
if got != "Done.Some final answer" {
t.Fatalf("unexpected sanitize result for agent XML leak: %q", got)
}
}
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

@@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int {
return -1 return -1
} }
lower := strings.ToLower(s) lower := strings.ToLower(s)
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} keywords := []string{"tool_calls", "\"function\"", "function.name:"}
bestKeyIdx := -1 bestKeyIdx := -1
for _, kw := range keywords { for _, kw := range keywords {
idx := strings.Index(lower, kw) idx := strings.Index(lower, kw)
@@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
lower := strings.ToLower(captured) lower := strings.ToLower(captured)
keyIdx := -1 keyIdx := -1
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} keywords := []string{"tool_calls", "\"function\"", "function.name:"}
for _, kw := range keywords { for _, kw := range keywords {
idx := strings.Index(lower, kw) idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
@@ -253,9 +253,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
} }
start := strings.LastIndex(captured[:keyIdx], "{") start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 { if start < 0 {
if blockStart, blockEnd, ok := extractToolHistoryBlock(captured, keyIdx); ok {
return captured[:blockStart], nil, captured[blockEnd:], true
}
start = keyIdx start = keyIdx
} }
obj, end, ok := extractJSONObjectFrom(captured, start) obj, end, ok := extractJSONObjectFrom(captured, start)

View File

@@ -44,31 +44,6 @@ func extractJSONObjectFrom(text string, start int) (string, int, bool) {
return "", 0, false return "", 0, false
} }
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) { func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n") trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```") fenceIdx := strings.LastIndex(trimmedPrefix, "```")

View File

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

View File

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

View File

@@ -21,6 +21,9 @@ type ConfigStore interface {
Update(mutator func(*config.Config) error) error Update(mutator func(*config.Config) error) error
ExportJSONAndBase64() (string, string, error) ExportJSONAndBase64() (string, string, error)
IsEnvBacked() bool IsEnvBacked() bool
IsEnvWritebackEnabled() bool
HasEnvConfigSource() bool
ConfigPath() string
SetVercelSync(hash string, ts int64) error SetVercelSync(hash string, ts int64) error
AdminPasswordHash() string AdminPasswordHash() string
AdminJWTExpireHours() int AdminJWTExpireHours() int
@@ -28,6 +31,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int
RuntimeTokenRefreshIntervalHours() int
AutoDeleteSessions() bool AutoDeleteSessions() bool
} }

View File

@@ -120,12 +120,6 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
next.ModelAliases[k] = v 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 { if incoming.Responses.StoreTTLSeconds > 0 {
next.Responses.StoreTTLSeconds = incoming.Responses.StoreTTLSeconds 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 { if incoming.Runtime.GlobalMaxInflight > 0 {
next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight next.Runtime.GlobalMaxInflight = incoming.Runtime.GlobalMaxInflight
} }
if incoming.Runtime.TokenRefreshIntervalHours > 0 {
next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours
}
} }
normalizeSettingsConfig(&next) normalizeSettingsConfig(&next)

View File

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

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 ( var (
adminCfg *config.AdminConfig adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig respCfg *config.ResponsesConfig
respCfg *config.ResponsesConfig embCfg *config.EmbeddingsConfig
embCfg *config.EmbeddingsConfig autoDeleteCfg *config.AutoDeleteConfig
autoDeleteCfg *config.AutoDeleteConfig claudeMap map[string]string
claudeMap map[string]string aliasMap map[string]string
aliasMap map[string]string
) )
if raw, ok := req["admin"].(map[string]any); ok { 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 { if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 720 { 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 cfg.JWTExpireHours = n
} }
@@ -50,59 +49,43 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists { if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 256 { 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 cfg.AccountMaxInflight = n
} }
if v, exists := raw["account_max_queue"]; exists { if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { 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 cfg.AccountMaxQueue = n
} }
if v, exists := raw["global_max_inflight"]; exists { if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { 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 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 { 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 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 { if raw, ok := req["responses"].(map[string]any); ok {
cfg := &config.ResponsesConfig{} cfg := &config.ResponsesConfig{}
if v, exists := raw["store_ttl_seconds"]; exists { if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 30 || n > 86400 { 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 cfg.StoreTTLSeconds = n
} }
@@ -150,5 +133,5 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
autoDeleteCfg = cfg 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), "default_password_warning": authn.UsingDefaultAdminKey(h.Store),
}, },
"runtime": map[string]any{ "runtime": map[string]any{
"account_max_inflight": h.Store.RuntimeAccountMaxInflight(), "account_max_inflight": h.Store.RuntimeAccountMaxInflight(),
"account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended), "account_max_queue": h.Store.RuntimeAccountMaxQueue(recommended),
"global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended), "global_max_inflight": h.Store.RuntimeGlobalMaxInflight(recommended),
"token_refresh_interval_hours": h.Store.RuntimeTokenRefreshIntervalHours(),
}, },
"toolcall": snap.Toolcall,
"responses": snap.Responses, "responses": snap.Responses,
"embeddings": snap.Embeddings, "embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete, "auto_delete": snap.AutoDelete,

View File

@@ -14,6 +14,9 @@ func validateMergedRuntimeSettings(current config.RuntimeConfig, incoming *confi
if incoming.GlobalMaxInflight > 0 { if incoming.GlobalMaxInflight > 0 {
merged.GlobalMaxInflight = incoming.GlobalMaxInflight merged.GlobalMaxInflight = incoming.GlobalMaxInflight
} }
if incoming.TokenRefreshIntervalHours > 0 {
merged.TokenRefreshIntervalHours = incoming.TokenRefreshIntervalHours
}
} }
return validateRuntimeSettings(merged) 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) { func TestUpdateSettingsValidation(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`) h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{ 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) { func TestUpdateSettingsValidationWithMergedRuntimeSnapshot(t *testing.T) {
h := newAdminTestHandler(t, `{ h := newAdminTestHandler(t, `{
"keys":["k1"], "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) { func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) {
hash := authn.HashAdminPassword("old-password") hash := authn.HashAdminPassword("old-password")
h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) 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) { func TestConfigImportRejectsInvalidRuntimeBounds(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`) h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{ payload := map[string]any{

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return return
@@ -45,13 +45,8 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if runtimeCfg.GlobalMaxInflight > 0 { if runtimeCfg.GlobalMaxInflight > 0 {
c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight c.Runtime.GlobalMaxInflight = runtimeCfg.GlobalMaxInflight
} }
} if runtimeCfg.TokenRefreshIntervalHours > 0 {
if toolcallCfg != nil { c.Runtime.TokenRefreshIntervalHours = runtimeCfg.TokenRefreshIntervalHours
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 responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 { if responsesCfg != nil && responsesCfg.StoreTTLSeconds > 0 {

View File

@@ -12,8 +12,6 @@ func normalizeSettingsConfig(c *config.Config) {
return return
} }
c.Admin.PasswordHash = strings.TrimSpace(c.Admin.PasswordHash) 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) 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) { 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") 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) == "" { if c.Embeddings.Provider != "" && strings.TrimSpace(c.Embeddings.Provider) == "" {
return fmt.Errorf("embeddings.provider cannot be empty") 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) { if runtime.GlobalMaxInflight != 0 && (runtime.GlobalMaxInflight < 1 || runtime.GlobalMaxInflight > 200000) {
return fmt.Errorf("runtime.global_max_inflight must be between 1 and 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 { if runtime.AccountMaxInflight > 0 && runtime.GlobalMaxInflight > 0 && runtime.GlobalMaxInflight < runtime.AccountMaxInflight {
return fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") 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 Pool *account.Pool
Login LoginFunc Login LoginFunc
mu sync.Mutex mu sync.Mutex
tokenRefreshedAt map[string]time.Time tokenRefreshedAt map[string]time.Time
tokenRefreshInterval time.Duration
} }
func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver { func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Resolver {
return &Resolver{ return &Resolver{
Store: store, Store: store,
Pool: pool, Pool: pool,
Login: login, Login: login,
tokenRefreshedAt: map[string]time.Time{}, tokenRefreshedAt: map[string]time.Time{},
tokenRefreshInterval: 6 * time.Hour,
} }
} }
@@ -232,10 +230,14 @@ func (r *Resolver) ensureManagedToken(ctx context.Context, a *RequestAuth) error
} }
func (r *Resolver) shouldForceRefresh(accountID string) bool { func (r *Resolver) shouldForceRefresh(accountID string) bool {
if r == nil || r.Store == nil {
return false
}
if strings.TrimSpace(accountID) == "" { if strings.TrimSpace(accountID) == "" {
return false return false
} }
if r.tokenRefreshInterval <= 0 { intervalHours := r.Store.RuntimeTokenRefreshIntervalHours()
if intervalHours <= 0 {
return false return false
} }
now := time.Now() now := time.Now()
@@ -246,7 +248,7 @@ func (r *Resolver) shouldForceRefresh(accountID string) bool {
r.tokenRefreshedAt[accountID] = now r.tokenRefreshedAt[accountID] = now
return false return false
} }
return now.Sub(last) >= r.tokenRefreshInterval return now.Sub(last) >= time.Duration(intervalHours)*time.Hour
} }
func (r *Resolver) markTokenRefreshedNow(accountID string) { 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) 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 { if strings.TrimSpace(c.Admin.PasswordHash) != "" || c.Admin.JWTExpireHours > 0 || c.Admin.JWTValidAfterUnix > 0 {
m["admin"] = c.Admin 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 m["runtime"] = c.Runtime
} }
if c.Compat.WideInputStrictOutput != nil { if c.Compat.WideInputStrictOutput != nil {
m["compat"] = c.Compat m["compat"] = c.Compat
} }
if strings.TrimSpace(c.Toolcall.Mode) != "" || strings.TrimSpace(c.Toolcall.EarlyEmitConfidence) != "" {
m["toolcall"] = c.Toolcall
}
if c.Responses.StoreTTLSeconds > 0 { if c.Responses.StoreTTLSeconds > 0 {
m["responses"] = c.Responses m["responses"] = c.Responses
} }
@@ -98,9 +95,7 @@ func (c *Config) UnmarshalJSON(b []byte) error {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
} }
case "toolcall": case "toolcall":
if err := json.Unmarshal(v, &c.Toolcall); err != nil { // Legacy field ignored. Toolcall policy is fixed and no longer configurable.
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "responses": case "responses":
if err := json.Unmarshal(v, &c.Responses); err != nil { if err := json.Unmarshal(v, &c.Responses); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
@@ -143,7 +138,6 @@ func (c Config) Clone() Config {
Compat: CompatConfig{ Compat: CompatConfig{
WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput), WideInputStrictOutput: cloneBoolPtr(c.Compat.WideInputStrictOutput),
}, },
Toolcall: c.Toolcall,
Responses: c.Responses, Responses: c.Responses,
Embeddings: c.Embeddings, Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete, AutoDelete: c.AutoDelete,

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"encoding/base64" "encoding/base64"
"errors"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -79,6 +80,136 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
} }
} }
func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
path := tmp.Name()
_ = tmp.Close()
_ = os.Remove(path)
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`)
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", path)
t.Setenv("DS2API_ENV_WRITEBACK", "1")
store := LoadStore()
if store.IsEnvBacked() {
t.Fatalf("expected writeback bootstrap to become file-backed immediately")
}
if err := store.Update(func(c *Config) error {
c.Accounts = append(c.Accounts, Account{Email: "new@example.com", Password: "p2"})
return nil
}); err != nil {
t.Fatalf("update failed: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read written config: %v", err)
}
if !strings.Contains(string(content), "seed@example.com") {
t.Fatalf("expected bootstrapped config to contain seed account, got: %s", content)
}
if !strings.Contains(string(content), "new@example.com") {
t.Fatalf("expected persisted config to contain added account, got: %s", content)
}
reloaded := LoadStore()
if reloaded.IsEnvBacked() {
t.Fatalf("expected reloaded store to prefer persisted config file")
}
accounts := reloaded.Accounts()
if len(accounts) != 2 {
t.Fatalf("expected 2 accounts after reload, got %d", len(accounts))
}
}
func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
path := tmp.Name()
_ = tmp.Close()
_ = os.Remove(path)
t.Setenv("DS2API_CONFIG_JSON", "{invalid-json")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", path)
t.Setenv("DS2API_ENV_WRITEBACK", "1")
cfg, fromEnv, loadErr := loadConfig()
if loadErr == nil {
t.Fatalf("expected loadConfig error for invalid env json")
}
if !fromEnv {
t.Fatalf("expected fromEnv=true when parsing env config fails")
}
if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 {
t.Fatalf("expected empty config on parse failure, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts))
}
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected no bootstrapped config file, stat err=%v", statErr)
}
}
func TestEnvBackedStoreWritebackFallsBackToPersistedFileOnInvalidEnvJSON(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
path := tmp.Name()
if _, err := tmp.WriteString(`{"keys":["file-key"],"accounts":[{"email":"persisted@example.com","password":"p"}]}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
_ = tmp.Close()
t.Setenv("DS2API_CONFIG_JSON", "{invalid-json")
t.Setenv("CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", path)
t.Setenv("DS2API_ENV_WRITEBACK", "1")
cfg, fromEnv, loadErr := loadConfig()
if loadErr != nil {
t.Fatalf("expected fallback to persisted file, got error: %v", loadErr)
}
if fromEnv {
t.Fatalf("expected fallback to file-backed mode")
}
if len(cfg.Keys) != 1 || cfg.Keys[0] != "file-key" {
t.Fatalf("unexpected keys after fallback: %#v", cfg.Keys)
}
if len(cfg.Accounts) != 1 || cfg.Accounts[0].Email != "persisted@example.com" {
t.Fatalf("unexpected accounts after fallback: %#v", cfg.Accounts)
}
}
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) { func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{ t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"email":"user@example.com","password":"p"}] "accounts":[{"email":"user@example.com","password":"p"}]

View File

@@ -40,12 +40,38 @@ func loadConfig() (Config, bool, error) {
} }
if rawCfg != "" { if rawCfg != "" {
cfg, err := parseConfigString(rawCfg) cfg, err := parseConfigString(rawCfg)
if err != nil {
if !IsVercel() && envWritebackEnabled() {
if fileCfg, fileErr := loadConfigFromFile(ConfigPath()); fileErr == nil {
return fileCfg, false, nil
}
}
return cfg, true, err
}
cfg.ClearAccountTokens() cfg.ClearAccountTokens()
cfg.DropInvalidAccounts() cfg.DropInvalidAccounts()
if IsVercel() || !envWritebackEnabled() {
return cfg, true, err
}
content, fileErr := os.ReadFile(ConfigPath())
if fileErr == nil {
var fileCfg Config
if unmarshalErr := json.Unmarshal(content, &fileCfg); unmarshalErr == nil {
fileCfg.DropInvalidAccounts()
return fileCfg, false, err
}
}
if errors.Is(fileErr, os.ErrNotExist) {
if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil {
return cfg, false, err
} else {
Logger.Warn("[config] env writeback bootstrap failed", "error", writeErr)
}
}
return cfg, true, err return cfg, true, err
} }
content, err := os.ReadFile(ConfigPath()) cfg, err := loadConfigFromFile(ConfigPath())
if err != nil { if err != nil {
if IsVercel() { if IsVercel() {
// Vercel one-click deploy may start without a writable/present config file. // Vercel one-click deploy may start without a writable/present config file.
@@ -54,16 +80,6 @@ func loadConfig() (Config, bool, error) {
} }
return Config{}, false, err return Config{}, false, err
} }
var cfg Config
if err := json.Unmarshal(content, &cfg); err != nil {
return Config{}, false, err
}
cfg.DropInvalidAccounts()
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
_ = os.WriteFile(ConfigPath(), b, 0o644)
}
}
if IsVercel() { if IsVercel() {
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors. // Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
return cfg, true, nil return cfg, true, nil
@@ -71,6 +87,24 @@ func loadConfig() (Config, bool, error) {
return cfg, false, nil return cfg, false, nil
} }
func loadConfigFromFile(path string) (Config, error) {
content, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
if err := json.Unmarshal(content, &cfg); err != nil {
return Config{}, err
}
cfg.DropInvalidAccounts()
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
_ = os.WriteFile(path, b, 0o644)
}
}
return cfg, nil
}
func (s *Store) Snapshot() Config { func (s *Store) Snapshot() Config {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -177,7 +211,7 @@ func (s *Store) Update(mutator func(*Config) error) error {
func (s *Store) Save() error { func (s *Store) Save() error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.fromEnv { if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
Logger.Info("[save_config] source from env, skip write") Logger.Info("[save_config] source from env, skip write")
return nil return nil
} }
@@ -187,11 +221,15 @@ func (s *Store) Save() error {
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(s.path, b, 0o644) if err := writeConfigBytes(s.path, b); err != nil {
return err
}
s.fromEnv = false
return nil
} }
func (s *Store) saveLocked() error { func (s *Store) saveLocked() error {
if s.fromEnv { if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
Logger.Info("[save_config] source from env, skip write") Logger.Info("[save_config] source from env, skip write")
return nil return nil
} }
@@ -201,7 +239,11 @@ func (s *Store) saveLocked() error {
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(s.path, b, 0o644) if err := writeConfigBytes(s.path, b); err != nil {
return err
}
s.fromEnv = false
return nil
} }
func (s *Store) IsEnvBacked() bool { func (s *Store) IsEnvBacked() bool {

View File

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

View File

@@ -0,0 +1,51 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
func envWritebackEnabled() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK")))
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func (s *Store) IsEnvWritebackEnabled() bool {
return envWritebackEnabled()
}
func (s *Store) HasEnvConfigSource() bool {
rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON"))
if rawCfg == "" {
rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON"))
}
return rawCfg != ""
}
func (s *Store) ConfigPath() string {
return s.path
}
func writeConfigFile(path string, cfg Config) error {
persistCfg := cfg.Clone()
persistCfg.ClearAccountTokens()
b, err := json.MarshalIndent(persistCfg, "", " ")
if err != nil {
return err
}
return writeConfigBytes(path, b)
}
func writeConfigBytes(path string, b []byte) error {
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return os.WriteFile(path, b, 0o644)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir config dir: %w", err)
}
return os.WriteFile(path, b, 0o644)
}

View File

@@ -12,12 +12,10 @@ function resolveToolcallPolicy(prepBody, payloadTools) {
if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) { if (toolNames.length === 0 && Array.isArray(payloadTools) && payloadTools.length > 0) {
toolNames = ['__any_tool__']; toolNames = ['__any_tool__'];
} }
const featureMatchEnabled = boolDefaultTrue(prepBody && prepBody.toolcall_feature_match);
const emitEarlyToolDeltas = featureMatchEnabled && boolDefaultTrue(prepBody && prepBody.toolcall_early_emit_high);
return { return {
toolNames, toolNames,
toolSieveEnabled: toolNames.length > 0, toolSieveEnabled: toolNames.length > 0,
emitEarlyToolDeltas, emitEarlyToolDeltas: true,
}; };
} }

View File

@@ -140,30 +140,6 @@ function extractJSONObjectFrom(text, start) {
return { ok: false, end: 0 }; return { ok: false, end: 0 };
} }
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) { function trimWrappingJSONFence(prefix, suffix) {
const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, ''); const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, '');
const fenceIdx = rightTrimmedPrefix.lastIndexOf('```'); const fenceIdx = rightTrimmedPrefix.lastIndexOf('```');
@@ -192,6 +168,5 @@ module.exports = {
parseJSONStringLiteral, parseJSONStringLiteral,
skipSpaces, skipSpaces,
extractJSONObjectFrom, extractJSONObjectFrom,
extractToolHistoryBlock,
trimWrappingJSONFence, trimWrappingJSONFence,
}; };

View File

@@ -102,7 +102,10 @@ function extractToolCallObjects(text) {
const obj = extractJSONObjectFrom(raw, start); const obj = extractJSONObjectFrom(raw, start);
if (obj.ok) { if (obj.ok) {
out.push(raw.slice(start, obj.end).trim()); 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; idx = -1;
break; break;
} }

View File

@@ -5,7 +5,7 @@ const {
insideCodeFenceWithState, insideCodeFenceWithState,
} = require('./state'); } = require('./state');
const { parseStandaloneToolCallsDetailed } = require('./parse'); const { parseStandaloneToolCallsDetailed } = require('./parse');
const { extractJSONObjectFrom, extractToolHistoryBlock, trimWrappingJSONFence } = require('./jsonscan'); const { extractJSONObjectFrom, trimWrappingJSONFence } = require('./jsonscan');
const { const {
TOOL_SEGMENT_KEYWORDS, TOOL_SEGMENT_KEYWORDS,
XML_TOOL_SEGMENT_TAGS, XML_TOOL_SEGMENT_TAGS,
@@ -233,17 +233,6 @@ function consumeToolCapture(state, toolNames) {
} }
const start = captured.slice(0, keyIdx).lastIndexOf('{'); const start = captured.slice(0, keyIdx).lastIndexOf('{');
const actualStart = start >= 0 ? start : keyIdx; 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); const obj = extractJSONObjectFrom(captured, actualStart);
if (!obj.ok) { if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };

View File

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

View File

@@ -4,8 +4,6 @@ const TOOL_SEGMENT_KEYWORDS = [
'tool_calls', 'tool_calls',
'"function"', '"function"',
'function.name:', 'function.name:',
'[tool_call_history]',
'[tool_result_history]',
]; ];
const XML_TOOL_SEGMENT_TAGS = [ const XML_TOOL_SEGMENT_TAGS = [

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

@@ -0,0 +1,31 @@
package sse
import "strings"
func filterLeakedContentFilterParts(parts []ContentPart) []ContentPart {
if len(parts) == 0 {
return parts
}
out := make([]ContentPart, 0, len(parts))
for _, p := range parts {
cleaned := stripLeakedContentFilterSuffix(p.Text)
if strings.TrimSpace(cleaned) == "" {
continue
}
p.Text = cleaned
out = append(out, p)
}
return out
}
func stripLeakedContentFilterSuffix(text string) string {
if text == "" {
return text
}
upperText := strings.ToUpper(text)
idx := strings.Index(upperText, "CONTENT_FILTER")
if idx < 0 {
return text
}
return strings.TrimRight(text[:idx], " \t\r\n")
}

View File

@@ -40,6 +40,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
} }
} }
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType) parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
parts = filterLeakedContentFilterParts(parts)
return LineResult{ return LineResult{
Parsed: true, Parsed: true,
Stop: finished, Stop: finished,

View File

@@ -35,3 +35,33 @@ func TestParseDeepSeekContentLineContent(t *testing.T) {
t.Fatalf("unexpected parts: %#v", res.Parts) t.Fatalf("unexpected parts: %#v", res.Parts)
} }
} }
func TestParseDeepSeekContentLineStripsLeakedContentFilterSuffix(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"正常输出CONTENT_FILTER你好这个问题我暂时无法回答"}`), false, "text")
if !res.Parsed || res.Stop {
t.Fatalf("expected parsed non-stop result: %#v", res)
}
if len(res.Parts) != 1 || res.Parts[0].Text != "正常输出" {
t.Fatalf("unexpected parts after filter: %#v", res.Parts)
}
}
func TestParseDeepSeekContentLineDropsPureLeakedContentFilterChunk(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"CONTENT_FILTER你好这个问题我暂时无法回答"}`), false, "text")
if !res.Parsed || res.Stop {
t.Fatalf("expected parsed non-stop result: %#v", res)
}
if len(res.Parts) != 0 {
t.Fatalf("expected empty parts, got %#v", res.Parts)
}
}
func TestParseDeepSeekContentLineTrimsFromContentFilterKeyword(t *testing.T) {
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"模型会在命中 CONTENT_FILTER 时返回拒绝原因。"}`), false, "text")
if !res.Parsed || res.Stop {
t.Fatalf("expected parsed non-stop result: %#v", res)
}
if len(res.Parts) != 1 || res.Parts[0].Text != "模型会在命中" {
t.Fatalf("unexpected parts after filter: %#v", res.Parts)
}
}

View File

@@ -64,7 +64,7 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text) lower := strings.ToLower(text)
out := []string{} out := []string{}
offset := 0 offset := 0
keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]"} keywords := []string{"tool_calls", "\"function\"", "function.name:"}
for { for {
bestIdx := -1 bestIdx := -1
matchedKeyword := "" matchedKeyword := ""

View File

@@ -6,14 +6,12 @@ import (
func TestParseTextKVToolCalls_Basic(t *testing.T) { func TestParseTextKVToolCalls_Basic(t *testing.T) {
text := ` text := `
[TOOL_CALL_HISTORY]
status: already_called status: already_called
origin: assistant origin: assistant
not_user_input: true not_user_input: true
tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b
function.name: execute_command function.name: execute_command
function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30} function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}
[/TOOL_CALL_HISTORY]
Some other text thinking... Some other text thinking...
` `

Binary file not shown.

View File

@@ -34,7 +34,7 @@ test('resolveToolcallPolicy defaults to feature-match + early emit when prepare
assert.equal(policy.emitEarlyToolDeltas, true); 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( const policy = resolveToolcallPolicy(
{ {
tool_names: [' prepped_tool ', '', null], 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.deepEqual(policy.toolNames, ['prepped_tool']);
assert.equal(policy.toolSieveEnabled, true); assert.equal(policy.toolSieveEnabled, true);
assert.equal(policy.emitEarlyToolDeltas, false); assert.equal(policy.emitEarlyToolDeltas, true);
}); });
test('normalizePreparedToolNames filters empty values', () => { test('normalizePreparedToolNames filters empty values', () => {

View File

@@ -98,10 +98,8 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co
test('parseToolCalls parses text-kv fallback payload', () => { test('parseToolCalls parses text-kv fallback payload', () => {
const text = [ const text = [
'[TOOL_CALL_HISTORY]',
'function.name: execute_command', 'function.name: execute_command',
'function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}', 'function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}',
'[/TOOL_CALL_HISTORY]',
'Some other text thinking...', 'Some other text thinking...',
].join('\n'); ].join('\n');
const calls = parseToolCalls(text, ['execute_command']); const calls = parseToolCalls(text, ['execute_command']);
@@ -229,6 +227,24 @@ test('sieve flushes incomplete captured XML tool blocks without leaking raw tags
assert.equal(leakedText.includes('<tool_call'), 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', () => { test('sieve still intercepts large tool json payloads over previous capture limit', () => {
const large = 'a'.repeat(9000); const large = 'a'.repeat(9000);
const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`; const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`;
@@ -254,54 +270,44 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); 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( const events = runSieve(
[ ['前置。', '这里提到 tool_calls 只是解释,不是调用。', '后置。'],
'前置文本。', ['read_file'],
'[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false);
});
test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => {
const events = runSieve(
[
'前置文本。',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'后置文本。',
],
['exec'],
);
const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
assert.equal(hasToolCall, false);
assert.equal(leakedText.includes('前置文本。'), true);
assert.equal(leakedText.includes('后置文本。'), true);
assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false);
});
test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => {
const events = runSieve(
[
'Hello ',
'[TOOL_RESULT_HISTORY]\nstatus: already_called\n',
'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
'world',
],
['exec'],
); );
const leakedText = collectText(events); const leakedText = collectText(events);
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
assert.equal(hasToolCall, false); assert.equal(hasToolCall, false);
assert.equal(leakedText, 'Hello world'); assert.equal(leakedText.includes('tool_calls'), true);
assert.equal(leakedText, '前置。这里提到 tool_calls 只是解释,不是调用。后置。');
});
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', () => { test('sieve emits unknown tool payload (no args) as executable tool call', () => {

View File

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

View File

@@ -64,6 +64,27 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{Boolean(config?.env_source_present) && (
<div className={`rounded-xl border px-4 py-3 text-sm ${
config?.env_writeback_enabled
? (config?.env_backed ? 'border-amber-500/30 bg-amber-500/10 text-amber-600' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600')
: 'border-amber-500/30 bg-amber-500/10 text-amber-600'
}`}>
<p className="font-medium">
{config?.env_writeback_enabled
? (config?.env_backed
? t('accountManager.envModeWritebackPendingTitle')
: t('accountManager.envModeWritebackActiveTitle'))
: t('accountManager.envModeRiskTitle')}
</p>
<p className="mt-1 text-xs opacity-90">
{config?.env_writeback_enabled
? t('accountManager.envModeWritebackDesc', { path: config?.config_path || 'config.json' })
: t('accountManager.envModeRiskDesc')}
</p>
</div>
)}
<QueueCards queueStatus={queueStatus} t={t} /> <QueueCards queueStatus={queueStatus} t={t} />
<ApiKeysPanel <ApiKeysPanel

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"> <div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.behaviorTitle')}</h3> <h3 className="font-semibold">{t('settings.behaviorTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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"> <label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.responsesTTL')}</span> <span className="text-muted-foreground">{t('settings.responsesTTL')}</span>
<input <input

View File

@@ -2,7 +2,7 @@ export default function RuntimeSection({ t, form, setForm }) {
return ( return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4"> <div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3> <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"> <label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span> <span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
<input <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" className="w-full bg-background border border-border rounded-lg px-3 py-2"
/> />
</label> </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>
</div> </div>
) )

View File

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

View File

@@ -138,7 +138,12 @@
"sessionCount": "Sessions: {count}", "sessionCount": "Sessions: {count}",
"deleteAllSessions": "Delete all sessions", "deleteAllSessions": "Delete all sessions",
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.", "deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
"deleteAllSessionsSuccess": "Successfully deleted all sessions" "deleteAllSessionsSuccess": "Successfully deleted all sessions",
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON/CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
"envModeWritebackActiveTitle": "Env mode + auto-persistence active",
"envModeWritebackDesc": "The app will auto-create/write the config file and transition to file-backed mode. Current persistence path: {path}"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "Hello, please introduce yourself in one sentence.", "defaultMessage": "Hello, please introduce yourself in one sentence.",
@@ -222,13 +227,12 @@
"passwordTooShort": "Password must be at least 4 characters.", "passwordTooShort": "Password must be at least 4 characters.",
"passwordUpdated": "Password updated. Please sign in again.", "passwordUpdated": "Password updated. Please sign in again.",
"passwordUpdateFailed": "Failed to update password.", "passwordUpdateFailed": "Failed to update password.",
"runtimeTitle": "Concurrency & Queue", "runtimeTitle": "Runtime",
"accountMaxInflight": "Per-account max inflight", "accountMaxInflight": "Per-account max inflight",
"accountMaxQueue": "Account max queue size", "accountMaxQueue": "Account max queue size",
"globalMaxInflight": "Global max inflight", "globalMaxInflight": "Global max inflight",
"tokenRefreshIntervalHours": "Managed token refresh interval (hours)",
"behaviorTitle": "Behavior", "behaviorTitle": "Behavior",
"toolcallMode": "Toolcall mode",
"earlyEmitConfidence": "Early emit confidence",
"responsesTTL": "Responses store TTL (seconds)", "responsesTTL": "Responses store TTL (seconds)",
"embeddingsProvider": "Embeddings provider", "embeddingsProvider": "Embeddings provider",
"modelTitle": "Model mapping", "modelTitle": "Model mapping",

View File

@@ -138,7 +138,12 @@
"sessionCount": "会话: {count}", "sessionCount": "会话: {count}",
"deleteAllSessions": "删除所有会话", "deleteAllSessions": "删除所有会话",
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。", "deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
"deleteAllSessionsSuccess": "删除成功" "deleteAllSessionsSuccess": "删除成功",
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON/CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK管理台改动仅在内存生效重启可能丢失。",
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
"envModeWritebackActiveTitle": "环境变量模式 + 自动持久化已生效",
"envModeWritebackDesc": "程序会自动创建/写入配置文件并在后续切换为文件模式。当前持久化路径:{path}"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "你好,请用一句话介绍你自己。", "defaultMessage": "你好,请用一句话介绍你自己。",
@@ -222,13 +227,12 @@
"passwordTooShort": "新密码至少 4 位", "passwordTooShort": "新密码至少 4 位",
"passwordUpdated": "密码已更新,需重新登录", "passwordUpdated": "密码已更新,需重新登录",
"passwordUpdateFailed": "密码更新失败", "passwordUpdateFailed": "密码更新失败",
"runtimeTitle": "并发与队列", "runtimeTitle": "运行时设置",
"accountMaxInflight": "每账号并发上限", "accountMaxInflight": "每账号并发上限",
"accountMaxQueue": "账号等待队列上限", "accountMaxQueue": "账号等待队列上限",
"globalMaxInflight": "全局并发上限", "globalMaxInflight": "全局并发上限",
"tokenRefreshIntervalHours": "托管账号 Token 刷新间隔(小时)",
"behaviorTitle": "行为设置", "behaviorTitle": "行为设置",
"toolcallMode": "Toolcall 模式",
"earlyEmitConfidence": "早发置信度",
"responsesTTL": "Responses 缓存 TTL", "responsesTTL": "Responses 缓存 TTL",
"embeddingsProvider": "Embeddings Provider", "embeddingsProvider": "Embeddings Provider",
"modelTitle": "模型映射", "modelTitle": "模型映射",