mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-07 01:45:27 +08:00
Compare commits
8 Commits
v2.5.0
...
v2.5.1_bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c95bf7b667 | ||
|
|
d79565b250 | ||
|
|
dc39de062b | ||
|
|
a7c9dfd7c0 | ||
|
|
822b14ed6b | ||
|
|
af7c7c6770 | ||
|
|
868a60b70b | ||
|
|
30a53b6c43 |
2
.github/workflows/release-artifacts.yml
vendored
2
.github/workflows/release-artifacts.yml
vendored
@@ -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
|
||||||
|
|||||||
11
API.en.md
11
API.en.md
@@ -629,24 +629,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 +670,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`
|
||||||
|
|
||||||
|
|||||||
11
API.md
11
API.md
@@ -638,24 +638,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 +679,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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
24
README.MD
24
README.MD
@@ -8,7 +8,7 @@
|
|||||||

|

|
||||||

|

|
||||||
[](https://github.com/CJackHwang/ds2api/releases)
|
[](https://github.com/CJackHwang/ds2api/releases)
|
||||||
[](DEPLOY.md)
|
[](docs/DEPLOY.md)
|
||||||
[](https://zeabur.com/templates/L4CFHP)
|
[](https://zeabur.com/templates/L4CFHP)
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
|
[](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 热更新)
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
@@ -450,6 +447,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 +467,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 +499,7 @@ npm ci --prefix webui && npm run build --prefix webui
|
|||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
详细测试指南请参阅 [TESTING.md](TESTING.md)。
|
详细测试指南请参阅 [docs/TESTING.md](docs/TESTING.md)。
|
||||||
|
|
||||||
### 快速测试命令
|
### 快速测试命令
|
||||||
|
|
||||||
|
|||||||
22
README.en.md
22
README.en.md
@@ -8,7 +8,7 @@
|
|||||||

|

|
||||||

|

|
||||||
[](https://github.com/CJackHwang/ds2api/releases)
|
[](https://github.com/CJackHwang/ds2api/releases)
|
||||||
[](DEPLOY.en.md)
|
[](docs/DEPLOY.en.md)
|
||||||
[](https://zeabur.com/templates/L4CFHP)
|
[](https://zeabur.com/templates/L4CFHP)
|
||||||
[](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
|
[](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
|
||||||
@@ -444,6 +441,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 +461,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
|
||||||
|
|
||||||
|
|||||||
@@ -456,8 +456,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -456,8 +456,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
54
internal/adapter/openai/leaked_output_sanitize.go
Normal file
54
internal/adapter/openai/leaked_output_sanitize.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 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], "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
43
internal/adapter/openai/leaked_output_sanitize_test.go
Normal file
43
internal/adapter/openai/leaked_output_sanitize_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, "```")
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,31 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||||
|
"keys":["k1"],
|
||||||
|
"accounts":[{"email":"u@example.com","password":"p"}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
store := LoadStore()
|
||||||
|
if got := store.RuntimeTokenRefreshIntervalHours(); got != 6 {
|
||||||
|
t.Fatalf("expected default refresh interval 6, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||||
|
"keys":["k1"],
|
||||||
|
"accounts":[{"email":"u@example.com","password":"p"}],
|
||||||
|
"runtime":{"token_refresh_interval_hours":9}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
store := LoadStore()
|
||||||
|
if got := store.RuntimeTokenRefreshIntervalHours(); got != 9 {
|
||||||
|
t.Fatalf("expected configured refresh interval 9, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
|
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"}]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: '' };
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
124
internal/prompt/tool_calls.go
Normal file
124
internal/prompt/tool_calls.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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>" + name + "</tool_name>\n" +
|
||||||
|
" <parameters>" + 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 ""
|
||||||
|
}
|
||||||
28
internal/prompt/tool_calls_test.go
Normal file
28
internal/prompt/tool_calls_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 := ""
|
||||||
|
|||||||
@@ -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.
@@ -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', () => {
|
||||||
|
|||||||
@@ -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']);
|
||||||
@@ -254,56 +252,6 @@ 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', () => {
|
|
||||||
const events = runSieve(
|
|
||||||
[
|
|
||||||
'前置文本。',
|
|
||||||
'[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]',
|
|
||||||
'后置文本。',
|
|
||||||
],
|
|
||||||
['exec'],
|
|
||||||
);
|
|
||||||
const leakedText = collectText(events);
|
|
||||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
|
|
||||||
assert.equal(hasToolCall, false);
|
|
||||||
assert.equal(leakedText.includes('前置文本。'), true);
|
|
||||||
assert.equal(leakedText.includes('后置文本。'), true);
|
|
||||||
assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => {
|
|
||||||
const events = runSieve(
|
|
||||||
[
|
|
||||||
'前置文本。',
|
|
||||||
'[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
|
|
||||||
'后置文本。',
|
|
||||||
],
|
|
||||||
['exec'],
|
|
||||||
);
|
|
||||||
const leakedText = collectText(events);
|
|
||||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls');
|
|
||||||
assert.equal(hasToolCall, false);
|
|
||||||
assert.equal(leakedText.includes('前置文本。'), true);
|
|
||||||
assert.equal(leakedText.includes('后置文本。'), true);
|
|
||||||
assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => {
|
|
||||||
const events = runSieve(
|
|
||||||
[
|
|
||||||
'Hello ',
|
|
||||||
'[TOOL_RESULT_HISTORY]\nstatus: already_called\n',
|
|
||||||
'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]',
|
|
||||||
'world',
|
|
||||||
],
|
|
||||||
['exec'],
|
|
||||||
);
|
|
||||||
const leakedText = collectText(events);
|
|
||||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
|
|
||||||
assert.equal(hasToolCall, false);
|
|
||||||
assert.equal(leakedText, 'Hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
|
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
|
||||||
const events = runSieve(
|
const events = runSieve(
|
||||||
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
|
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -222,13 +222,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",
|
||||||
|
|||||||
@@ -222,13 +222,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": "模型映射",
|
||||||
|
|||||||
Reference in New Issue
Block a user