mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
Compare commits
34 Commits
27eb73d48b
...
94c1acace5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c1acace5 | ||
|
|
273c18ba0f | ||
|
|
ae28e33184 | ||
|
|
0438ce9a12 | ||
|
|
af4a067dab | ||
|
|
33f6fef015 | ||
|
|
6d3979a1d6 | ||
|
|
c8922c7a88 | ||
|
|
241334c658 | ||
|
|
d7e071b24a | ||
|
|
89225c778e | ||
|
|
22160de2c4 | ||
|
|
0cbc2c875d | ||
|
|
a0984ef682 | ||
|
|
babfa973d6 | ||
|
|
ba4071d8b5 | ||
|
|
e1f8e493d2 | ||
|
|
907104a735 | ||
|
|
2c8409dcbb | ||
|
|
5c23261932 | ||
|
|
d7125ea106 | ||
|
|
929d9a8ef7 | ||
|
|
c03f733b83 | ||
|
|
047fc9bee2 | ||
|
|
52558838ef | ||
|
|
f1926a6ced | ||
|
|
6e21714e23 | ||
|
|
48c4f0df9f | ||
|
|
a550de30af | ||
|
|
23422e4a8e | ||
|
|
9c33bed403 | ||
|
|
c81294f1b7 | ||
|
|
28d2b0410f | ||
|
|
0c782407f5 |
10
API.en.md
10
API.en.md
@@ -199,8 +199,7 @@ No auth required. Returns the currently supported DeepSeek native model list.
|
||||
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
||||
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -224,6 +223,8 @@ Built-in aliases come from `internal/config/models.go`; `config.model_aliases` c
|
||||
- Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-pro-vision`
|
||||
- Other compatibility families: `llama-*`, `qwen-*`, `mistral-*`, and `command-*` fall back through family heuristics
|
||||
|
||||
Current vision support resolves only to `deepseek-v4-vision` and does not expose a separate `vision-search` variant.
|
||||
|
||||
Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant-*`, and `gpt-3.5*` are explicitly rejected.
|
||||
|
||||
### `POST /v1/chat/completions`
|
||||
@@ -917,12 +918,15 @@ Updates proxy binding for a specific account.
|
||||
"message": "API test successful (session creation only)",
|
||||
"model": "deepseek-v4-flash",
|
||||
"session_count": 0,
|
||||
"config_writable": true
|
||||
"config_writable": true,
|
||||
"config_warning": ""
|
||||
}
|
||||
```
|
||||
|
||||
If a `message` is provided, `thinking` may also be included when the upstream response carries reasoning text.
|
||||
|
||||
When the configured file path is not writable (for example, read-only `/app/config.json` inside some containers), login/session testing still proceeds; `config_warning` is returned to indicate token persistence failed and the token is memory-only until restart.
|
||||
|
||||
### `POST /admin/accounts/test-all`
|
||||
|
||||
Optional request field: `model`.
|
||||
|
||||
10
API.md
10
API.md
@@ -204,9 +204,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
||||
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-pro-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||
{"id": "deepseek-v4-vision-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
||||
{"id": "deepseek-v4-vision-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -232,6 +230,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
||||
- 其他兼容族:`llama-*`、`qwen-*`、`mistral-*`、`command-*` 会按家族启发式回退
|
||||
|
||||
上述 alias 若在请求名后追加 `-nothinking` 后缀,也会映射到对应的强制关闭 thinking 版本。
|
||||
当前视觉能力仅对应 `deepseek-v4-vision` / `deepseek-v4-vision-nothinking`,不会解析出独立的 `vision-search` 变体。
|
||||
|
||||
退役历史模型(如 `claude-1.*`、`claude-2.*`、`claude-instant-*`、`gpt-3.5*`)会被显式拒绝。
|
||||
|
||||
@@ -934,12 +933,15 @@ data: {"type":"message_stop"}
|
||||
"message": "API 测试成功(仅会话创建)",
|
||||
"model": "deepseek-v4-flash",
|
||||
"session_count": 0,
|
||||
"config_writable": true
|
||||
"config_writable": true,
|
||||
"config_warning": ""
|
||||
}
|
||||
```
|
||||
|
||||
如果传入 `message`,还会附带 `thinking`(当上游返回思考内容时)。
|
||||
|
||||
当部署环境配置文件路径不可写(例如容器内默认 `/app/config.json` 只读)时,登录与会话测试仍可继续;此时会返回 `config_warning` 提示 token 仅保存在内存、重启后丢失。
|
||||
|
||||
### `POST /admin/accounts/test-all`
|
||||
|
||||
可选请求字段:`model`
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -28,6 +28,8 @@ FROM debian:bookworm-slim AS runtime-base
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& groupadd -r ds2api && useradd -r -g ds2api -d /app -s /sbin/nologin ds2api \
|
||||
&& mkdir -p /app/data && chown -R ds2api:ds2api /app \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=busybox-tools /bin/busybox /usr/local/bin/busybox
|
||||
EXPOSE 5001
|
||||
@@ -36,8 +38,9 @@ CMD ["/usr/local/bin/ds2api"]
|
||||
FROM runtime-base AS runtime-from-source
|
||||
COPY --from=go-builder /out/ds2api /usr/local/bin/ds2api
|
||||
|
||||
COPY --from=go-builder /app/config.example.json /app/config.example.json
|
||||
COPY --from=webui-builder /app/static/admin /app/static/admin
|
||||
COPY --from=go-builder --chown=ds2api:ds2api /app/config.example.json /app/config.example.json
|
||||
COPY --from=webui-builder --chown=ds2api:ds2api /app/static/admin /app/static/admin
|
||||
USER ds2api
|
||||
|
||||
FROM busybox-tools AS dist-extract
|
||||
ARG TARGETARCH
|
||||
@@ -60,7 +63,8 @@ RUN set -eux; \
|
||||
FROM runtime-base AS runtime-from-dist
|
||||
COPY --from=dist-extract /out/ds2api /usr/local/bin/ds2api
|
||||
|
||||
COPY --from=dist-extract /out/config.example.json /app/config.example.json
|
||||
COPY --from=dist-extract /out/static/admin /app/static/admin
|
||||
COPY --from=dist-extract --chown=ds2api:ds2api /out/config.example.json /app/config.example.json
|
||||
COPY --from=dist-extract --chown=ds2api:ds2api /out/static/admin /app/static/admin
|
||||
USER ds2api
|
||||
|
||||
FROM runtime-from-source AS final
|
||||
|
||||
30
README.MD
30
README.MD
@@ -31,6 +31,30 @@
|
||||
>
|
||||
> 请勿将本项目用于违反服务条款、协议、法律法规或平台规则的场景。商业使用前请自行确认 `LICENSE`、相关协议以及你是否获得了作者的书面许可。
|
||||
|
||||
## 目录
|
||||
|
||||
- [架构概览(摘要)](#架构概览摘要)
|
||||
- [核心能力](#核心能力)
|
||||
- [平台兼容矩阵](#平台兼容矩阵)
|
||||
- [模型支持](#模型支持)
|
||||
- [OpenAI 接口](#openai-接口get-v1models)
|
||||
- [Claude 接口](#claude-接口get-anthropicv1models)
|
||||
- [Gemini 接口](#gemini-接口)
|
||||
- [快速开始](#快速开始)
|
||||
- [方式一:下载 Release 构建包](#方式一下载-release-构建包)
|
||||
- [方式二:Docker 运行](#方式二docker-运行)
|
||||
- [方式三:Vercel 部署](#方式三vercel-部署)
|
||||
- [方式四:本地源码运行](#方式四本地源码运行)
|
||||
- [配置说明](#配置说明)
|
||||
- [鉴权模式](#鉴权模式)
|
||||
- [并发模型](#并发模型)
|
||||
- [Tool Call 适配](#tool-call-适配)
|
||||
- [本地开发抓包工具](#本地开发抓包工具)
|
||||
- [文档索引](#文档索引)
|
||||
- [测试](#测试)
|
||||
- [Release 自动构建(GitHub Actions)](#release-自动构建github-actions)
|
||||
- [免责声明](#免责声明)
|
||||
|
||||
## 架构概览(摘要)
|
||||
|
||||
```mermaid
|
||||
@@ -134,10 +158,9 @@ flowchart LR
|
||||
| expert | `deepseek-v4-pro-search-nothinking` | 永久关闭,不受请求参数影响 | ✅ |
|
||||
| vision | `deepseek-v4-vision` | 默认开启,可由请求参数控制 | ❌ |
|
||||
| vision | `deepseek-v4-vision-nothinking` | 永久关闭,不受请求参数影响 | ❌ |
|
||||
| vision | `deepseek-v4-vision-search` | 默认开启,可由请求参数控制 | ✅ |
|
||||
| vision | `deepseek-v4-vision-search-nothinking` | 永久关闭,不受请求参数影响 | ✅ |
|
||||
|
||||
除原生模型外,也支持常见 alias 输入(如 `gpt-4.1`、`gpt-5`、`gpt-5-codex`、`o3`、`claude-*`、`gemini-*` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。若 alias 名本身追加 `-nothinking` 后缀,也会映射到对应的强制关思考模型。完整 alias 行为以 [API.md](API.md#模型-alias-解析策略) 和 `config.example.json` 为准。
|
||||
当前上游视觉模型只暴露 `vision` 通道,不提供独立的联网搜索视觉变体。
|
||||
|
||||
### Claude 接口(`GET /anthropic/v1/models`)
|
||||
|
||||
@@ -221,6 +244,7 @@ docker-compose logs -f
|
||||
```
|
||||
|
||||
默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。
|
||||
同时默认把 `./config.json` 挂载到容器 `/data/config.json`,并设置 `DS2API_CONFIG_PATH=/data/config.json`,用于避免 `/app` 只读导致运行时 token 持久化失败。
|
||||
|
||||
更新镜像:`docker-compose up -d --build`
|
||||
|
||||
@@ -291,7 +315,7 @@ go run ./cmd/ds2api
|
||||
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
|
||||
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
||||
- `history_split`:旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置。
|
||||
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件。
|
||||
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为 `history.txt` 上下文文件。
|
||||
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
|
||||
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。
|
||||
|
||||
|
||||
29
README.en.md
29
README.en.md
@@ -28,6 +28,30 @@ Documentation entry: [Docs Index](docs/README.md) / [Architecture](docs/ARCHITEC
|
||||
>
|
||||
> Do not use this project in ways that violate service terms, agreements, laws, or platform rules. Before any commercial use, review the `LICENSE`, the relevant terms, and confirm that you have the author's written permission.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Architecture Overview (Summary)](#architecture-overview-summary)
|
||||
- [Key Capabilities](#key-capabilities)
|
||||
- [Platform Compatibility Matrix](#platform-compatibility-matrix)
|
||||
- [Model Support](#model-support)
|
||||
- [OpenAI Endpoint](#openai-endpoint-get-v1models)
|
||||
- [Claude Endpoint](#claude-endpoint-get-anthropicv1models)
|
||||
- [Gemini Endpoint](#gemini-endpoint)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Option 1: Download Release Binaries](#option-1-download-release-binaries)
|
||||
- [Option 2: Docker / GHCR](#option-2-docker--ghcr)
|
||||
- [Option 3: Vercel](#option-3-vercel)
|
||||
- [Option 4: Local Run](#option-4-local-run)
|
||||
- [Configuration](#configuration)
|
||||
- [Authentication Modes](#authentication-modes)
|
||||
- [Concurrency Model](#concurrency-model)
|
||||
- [Tool Call Adaptation](#tool-call-adaptation)
|
||||
- [Local Dev Packet Capture](#local-dev-packet-capture)
|
||||
- [Documentation Index](#documentation-index)
|
||||
- [Testing](#testing)
|
||||
- [Release Artifact Automation (GitHub Actions)](#release-artifact-automation-github-actions)
|
||||
- [Disclaimer](#disclaimer)
|
||||
|
||||
## Architecture Overview (Summary)
|
||||
|
||||
```mermaid
|
||||
@@ -126,9 +150,9 @@ For the full module-by-module architecture and directory responsibilities, see [
|
||||
| default | `deepseek-v4-flash-search` | enabled by default, request-controlled | ✅ |
|
||||
| expert | `deepseek-v4-pro-search` | enabled by default, request-controlled | ✅ |
|
||||
| vision | `deepseek-v4-vision` | enabled by default, request-controlled | ❌ |
|
||||
| vision | `deepseek-v4-vision-search` | enabled by default, request-controlled | ✅ |
|
||||
|
||||
Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-4.1`, `gpt-5`, `gpt-5-codex`, `o3`, `claude-*`, `gemini-*`), but `/v1/models` returns normalized DeepSeek native model IDs. The complete alias behavior is documented in [API.en.md](API.en.md#model-alias-resolution) and `config.example.json`.
|
||||
Current upstream vision support exposes only the `vision` lane and does not provide a separate search-enabled vision variant.
|
||||
|
||||
### Claude Endpoint (`GET /anthropic/v1/models`)
|
||||
|
||||
@@ -209,6 +233,7 @@ docker-compose up -d
|
||||
```
|
||||
|
||||
The default `docker-compose.yml` uses `ghcr.io/cjackhwang/ds2api:latest` and maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping).
|
||||
It also mounts `./config.json` to `/data/config.json` and sets `DS2API_CONFIG_PATH=/data/config.json` by default, which avoids runtime token persistence failures caused by read-only `/app`.
|
||||
|
||||
Rebuild after updates: `docker-compose up -d --build`
|
||||
|
||||
@@ -279,7 +304,7 @@ Common fields:
|
||||
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
||||
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
||||
- `history_split`: legacy multi-turn history split field, now ignored and kept only for backward-compatible config loading.
|
||||
- `current_input_file`: the only active split mode; it is enabled by default and uploads the full context as a hidden context file once the character threshold is reached.
|
||||
- `current_input_file`: the only active split mode; it is enabled by default and uploads the full context as a `history.txt` context file once the character threshold is reached.
|
||||
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
|
||||
|
||||
For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication).
|
||||
|
||||
@@ -35,8 +35,9 @@ func main() {
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Handler: app.Router,
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Handler: app.Router,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
localURL := fmt.Sprintf("http://127.0.0.1:%s", port)
|
||||
lanIP := detectLANIPv4()
|
||||
|
||||
@@ -9,8 +9,9 @@ services:
|
||||
# Host port is configurable via DS2API_HOST_PORT; container port stays fixed at 5001.
|
||||
- "${DS2API_HOST_PORT:-6011}:5001"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json # 配置文件
|
||||
- ./config.json:/data/config.json # 配置文件(持久化推荐路径)
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- LOG_LEVEL=INFO
|
||||
- DS2API_ADMIN_KEY=${DS2API_ADMIN_KEY:-ds2api}
|
||||
- DS2API_CONFIG_PATH=/data/config.json
|
||||
|
||||
@@ -130,6 +130,8 @@ docker-compose logs -f
|
||||
```
|
||||
|
||||
The default `docker-compose.yml` directly uses `ghcr.io/cjackhwang/ds2api:latest` and maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping).
|
||||
The compose template also defaults to `DS2API_CONFIG_PATH=/data/config.json` with `./config.json:/data/config.json` mounted, so deployments avoid read-only `/app` persistence issues by default.
|
||||
Compatibility note: when `DS2API_CONFIG_PATH` is unset and runtime base dir is `/app`, newer versions prefer `/data/config.json`; if that file is missing but legacy `/app/config.json` exists, DS2API automatically falls back to the legacy path to avoid post-upgrade config loss.
|
||||
|
||||
If you want a pinned version instead of `latest`, you can also pull a specific tag directly:
|
||||
|
||||
@@ -195,6 +197,11 @@ Notes:
|
||||
|
||||
- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`.
|
||||
- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path.
|
||||
- **`open /app/config.json: permission denied`**: this means the instance is trying to persist runtime tokens to a read-only path (commonly `/app` inside the image).
|
||||
Recommended handling:
|
||||
1. Set a writable path explicitly: `DS2API_CONFIG_PATH=/data/config.json` (and mount a persistent volume at `/data`);
|
||||
2. If you bootstrap with `DS2API_CONFIG_JSON` and do not need runtime writeback, keep env-backed mode (`DS2API_ENV_WRITEBACK` disabled);
|
||||
3. In current versions, login/session tests continue even if persistence fails; Admin API returns a warning that token persistence failed and token is memory-only until restart.
|
||||
- **Build version**: Zeabur / regular `docker build` does not require `BUILD_VERSION` by default. The image prefers that build arg when provided, and automatically falls back to the repo-root `VERSION` file when it is absent.
|
||||
- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login).
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ docker-compose logs -f
|
||||
```
|
||||
|
||||
默认 `docker-compose.yml` 直接使用 `ghcr.io/cjackhwang/ds2api:latest`,并把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。
|
||||
Compose 模板还会默认设置 `DS2API_CONFIG_PATH=/data/config.json` 并挂载 `./config.json:/data/config.json`,优先避免 `/app` 只读带来的配置持久化问题。
|
||||
兼容说明:若未设置 `DS2API_CONFIG_PATH` 且运行目录是 `/app`,新版本会优先使用 `/data/config.json`;当该文件不存在但检测到历史 `/app/config.json` 时,会自动回退读取旧路径,避免升级后“配置丢失”。
|
||||
|
||||
如需固定版本,也可以直接拉取指定 tag:
|
||||
|
||||
@@ -195,6 +197,11 @@ healthcheck:
|
||||
|
||||
- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。
|
||||
- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。
|
||||
- **`open /app/config.json: permission denied`**:说明当前实例在尝试把运行时 token 持久化到只读路径(常见于镜像内 `/app`)。
|
||||
处理建议:
|
||||
1. 显式设置可写路径:`DS2API_CONFIG_PATH=/data/config.json`(并挂载持久卷到 `/data`);
|
||||
2. 若你使用 `DS2API_CONFIG_JSON` 启动且不需要运行时落盘,可保持环境变量模式(`DS2API_ENV_WRITEBACK` 关闭);
|
||||
3. 最新版本中,即使持久化失败,登录/会话测试仍会继续,仅提示“token 未持久化(重启后丢失)”。
|
||||
- **构建版本号**:Zeabur / 普通 `docker build` 默认不需要传 `BUILD_VERSION`;镜像会优先使用该构建参数,未提供时自动回退到仓库根目录的 `VERSION` 文件。
|
||||
- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。
|
||||
|
||||
|
||||
@@ -309,7 +309,7 @@ parse SSE block
|
||||
- 新模型可能增加新的 `p` 路径。
|
||||
- 新版本可能增加新的 fragment.type。
|
||||
- `CONTENT_FILTER` 的终态模板内容可能变化。
|
||||
- 自动续写相关状态(如 `INCOMPLETE` / `AUTO_CONTINUE`)当前主要来自实测与实现兼容逻辑,后续字段形态仍可能变化。
|
||||
- 自动续写相关状态(如 `INCOMPLETE` / `AUTO_CONTINUE`)当前主要来自实测与实现兼容逻辑,后续字段形态仍可能变化。当前实现不会仅因早期 `WIP` 状态就自动继续;只有显式 `INCOMPLETE` 或 `auto_continue` 信号才会触发 continue。
|
||||
- 解析器应当对未知字段、未知路径、未知事件保持容忍。
|
||||
|
||||
如果你要把这份说明用于实际开发,建议同时保留原始流样本、回放脚本和回归测试,不要只依赖本文。
|
||||
|
||||
@@ -102,9 +102,11 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
- 但 DeepSeek 远端本身支持同一 `chat_session_id` 的跨轮次持续对话。2026-04-27 已用项目内现有 DeepSeek client 做过一次不改业务代码的双轮实测:同一 `chat_session_id` 下,第 1 轮返回 `request_message_id=1` / `response_message_id=2` / 文本 `SESSION_TEST_ONE`;第 2 轮重新获取一次 PoW,并发送 `parent_message_id=2` 后,成功返回 `request_message_id=3` / `response_message_id=4` / 文本 `SESSION_TEST_TWO`。这说明“同远端会话持续聊天”能力存在,且每轮需要携带正确的 parent/message 链接信息,同时重新获取对应轮次可用的 PoW。
|
||||
- OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `promptcompat.BuildOpenAIPromptForAdapter`,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。
|
||||
- 客户端传入的 thinking / reasoning 开关会被归一到下游 `thinking_enabled`。Gemini `generationConfig.thinkingConfig.thinkingBudget` 会翻译成同一套 thinking 开关;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。若最终解析出的模型名带 `-nothinking` 后缀,则会无条件强制关闭 thinking,优先级高于请求体中的 `thinking` / `reasoning` / `reasoning_effort`。Claude surface 在流式请求且未显式声明 `thinking` 时,仍按 Anthropic 语义默认关闭;但在非流式代理场景,兼容层会内部开启一次下游 thinking,用于捕获“正文为空、工具调用落在 thinking 里”的情况,随后在回包前剥离用户不可见的 thinking block。
|
||||
- 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;thinking / reasoning 增量仍按原样先发,只有在结束收尾时才可能补发最终工具调用结果。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning,思维链只用于检测,不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。
|
||||
- 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;真正的工具识别始终基于原始上游文本,而不是基于“已经做过可见输出清洗”的版本,因此即使最终可见层会剥离完整 leaked DSML / XML `tool_calls` wrapper、并抑制全空参数或无效 wrapper 块,也不会影响真实工具调用转成结构化 `tool_calls` / `function_call`。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning,思维链只用于检测,不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。
|
||||
- OpenAI Chat / Responses 的空回复错误处理之前会默认做一次内部补偿重试:第一次上游完整结束后,如果最终可见正文为空、没有解析到工具调用、也没有已经向客户端流式发出工具调用,并且终止原因不是 `content_filter`,兼容层会复用同一个 `chat_session_id`、账号、token 与工具策略,把原始 completion `prompt` 追加固定后缀 `Previous reply had no visible output. Please regenerate the visible final answer or tool call now.` 后重新提交一次。重试遵循 DeepSeek 多轮对话协议:从第一次上游 SSE 流中提取 `response_message_id`,并在重试 payload 中设置 `parent_message_id` 为该值,使重试成为同一会话的后续轮次而非断裂的根消息;同时重新获取一次 PoW(若 PoW 获取失败则回退到原始 PoW)。该重试不会重新标准化消息、不会新建 session、不会切换账号,也不会向流式客户端插入重试标记;第二次 thinking / reasoning 会按正常增量直接接到第一次之后,并继续使用 overlap trim 去重。若第二次仍为空,终端错误码仍保持现有 `upstream_empty_output`;若任一尝试触发空 `content_filter`,不做补偿重试并保持 `content_filter` 错误。JS Vercel 运行时同样设置 `parent_message_id`,但因无法直接调用 PoW API 而复用原始 PoW。
|
||||
|
||||
- OpenAI Chat / Responses 在最终可见正文渲染阶段,会把 DeepSeek 搜索返回中的 `[citation:N]` / `[reference:N]` 标记替换成对应 Markdown 链接。`citation` 标记按一基序号解析;`reference` 标记只有在同一段正文中出现 `[reference:0]`(允许冒号后有空格)时才按零基序号映射,并且不会影响同段正文里的 `citation` 标记。
|
||||
|
||||
## 5. prompt 是怎么拼出来的
|
||||
|
||||
OpenAI Chat / Responses 在标准化后、current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考增强提示词。当前内置默认提示词以 `Reasoning Effort: Absolute maximum with no shortcuts permitted.` 开头,并继续要求模型充分分解问题、覆盖潜在路径与边界条件、把完整推演过程显式写出。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。
|
||||
@@ -154,6 +156,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
|
||||
兼容层仍接受旧式纯 `<tool_calls>` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
|
||||
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `<b>urgent</b>` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。
|
||||
在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。
|
||||
工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtime(Claude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object,还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter,避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。
|
||||
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
|
||||
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
|
||||
|
||||
@@ -243,7 +246,7 @@ OpenAI 文件相关实现:
|
||||
|
||||
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。
|
||||
|
||||
- `current_input_file` 默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `IGNORE.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
|
||||
- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `history.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `history.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
|
||||
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
|
||||
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
|
||||
|
||||
@@ -256,16 +259,11 @@ OpenAI 文件相关实现:
|
||||
- 旧历史拆分兼容壳:
|
||||
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
|
||||
|
||||
当前输入转文件启用并触发时,上传文件的真实文件名是 `IGNORE.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 `IGNORE` 文件边界里:
|
||||
当前输入转文件启用并触发时,上传文件的真实文件名是 `history.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,并直接作为 `history.txt` 的纯文本内容上传(不再注入文件边界标签):
|
||||
|
||||
```text
|
||||
[uploaded filename]: IGNORE.txt
|
||||
[file content end]
|
||||
|
||||
[uploaded filename]: history.txt
|
||||
<|begin▁of▁sentence|><|System|>...<|User|>...<|Assistant|>...<|Tool|>...<|User|>...
|
||||
|
||||
[file name]: IGNORE
|
||||
[file content begin]
|
||||
```
|
||||
|
||||
开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`。
|
||||
@@ -332,7 +330,7 @@ OpenAI 文件相关实现:
|
||||
|
||||
- 大部分结构化语义被压进 `prompt`
|
||||
- 文件保持文件
|
||||
- 需要时把完整上下文拆进隐藏上下文文件
|
||||
- 需要时把完整上下文拆进 `history.txt` 上下文文件
|
||||
|
||||
## 12. 修改时必须同步本文档的场景
|
||||
|
||||
@@ -345,7 +343,7 @@ OpenAI 文件相关实现:
|
||||
- tool result 注入方式变更
|
||||
- tool prompt 模板或 tool_choice 约束变更
|
||||
- inline 文件上传 / 文件引用收集规则变更
|
||||
- current input file 触发条件、上传格式、`IGNORE` 包装格式变更
|
||||
- current input file 触发条件、上传格式、`history.txt` 包装格式变更
|
||||
- 旧 `history_split` 兼容逻辑的读取、忽略或退化行为变更
|
||||
- completion payload 字段语义变更
|
||||
- Claude / Gemini 对这套统一语义的复用关系变更
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"ds2api/internal/config"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -610,8 +611,8 @@ func buildPreview(item Entry) string {
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(item.UserInput)
|
||||
}
|
||||
if len(candidate) > defaultPreviewAt {
|
||||
return candidate[:defaultPreviewAt] + "..."
|
||||
if truncated, ok := util.TruncateRunes(candidate, defaultPreviewAt); ok {
|
||||
return truncated + "..."
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func blockDetailDir(t *testing.T, detailDir string) func() {
|
||||
@@ -105,6 +106,17 @@ func TestStoreCreatesAndPersistsEntries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreviewPreservesUTF8MB4Characters(t *testing.T) {
|
||||
long := strings.Repeat("😀", defaultPreviewAt+1)
|
||||
preview := buildPreview(Entry{Content: long})
|
||||
if !utf8.ValidString(preview) {
|
||||
t.Fatalf("expected valid utf-8 preview, got %q", preview)
|
||||
}
|
||||
if preview != strings.Repeat("😀", defaultPreviewAt)+"..." {
|
||||
t.Fatalf("unexpected preview: %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTrimsToConfiguredLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "chat_history.json")
|
||||
store := New(path)
|
||||
|
||||
@@ -79,13 +79,20 @@ func TestGetModelConfigDeepSeekExpertReasonerSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfigDeepSeekVisionReasonerSearch(t *testing.T) {
|
||||
thinking, search, ok := GetModelConfig("deepseek-v4-vision-search")
|
||||
func TestGetModelConfigDeepSeekVision(t *testing.T) {
|
||||
thinking, search, ok := GetModelConfig("deepseek-v4-vision")
|
||||
if !ok {
|
||||
t.Fatal("expected ok for deepseek-v4-vision-search")
|
||||
t.Fatal("expected ok for deepseek-v4-vision")
|
||||
}
|
||||
if !thinking || !search {
|
||||
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
||||
if !thinking || search {
|
||||
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelConfigDeepSeekVisionSearchUnsupported(t *testing.T) {
|
||||
_, _, ok := GetModelConfig("deepseek-v4-vision-search")
|
||||
if ok {
|
||||
t.Fatal("expected deepseek-v4-vision-search to be unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,18 +755,16 @@ func TestOpenAIModelsResponse(t *testing.T) {
|
||||
t.Fatal("expected non-empty models list")
|
||||
}
|
||||
expected := map[string]bool{
|
||||
"deepseek-v4-flash": false,
|
||||
"deepseek-v4-flash-nothinking": false,
|
||||
"deepseek-v4-pro": false,
|
||||
"deepseek-v4-pro-nothinking": false,
|
||||
"deepseek-v4-flash-search": false,
|
||||
"deepseek-v4-flash-search-nothinking": false,
|
||||
"deepseek-v4-pro-search": false,
|
||||
"deepseek-v4-pro-search-nothinking": false,
|
||||
"deepseek-v4-vision": false,
|
||||
"deepseek-v4-vision-nothinking": false,
|
||||
"deepseek-v4-vision-search": false,
|
||||
"deepseek-v4-vision-search-nothinking": false,
|
||||
"deepseek-v4-flash": false,
|
||||
"deepseek-v4-flash-nothinking": false,
|
||||
"deepseek-v4-pro": false,
|
||||
"deepseek-v4-pro-nothinking": false,
|
||||
"deepseek-v4-flash-search": false,
|
||||
"deepseek-v4-flash-search-nothinking": false,
|
||||
"deepseek-v4-pro-search": false,
|
||||
"deepseek-v4-pro-search-nothinking": false,
|
||||
"deepseek-v4-vision": false,
|
||||
"deepseek-v4-vision-nothinking": false,
|
||||
}
|
||||
for _, model := range data {
|
||||
if _, ok := expected[model.ID]; ok {
|
||||
|
||||
@@ -144,10 +144,17 @@ func TestResolveModelCustomAliasToExpert(t *testing.T) {
|
||||
|
||||
func TestResolveModelCustomAliasToVision(t *testing.T) {
|
||||
got, ok := ResolveModel(mockModelAliasReader{
|
||||
"my-vision-model": "deepseek-v4-vision-search",
|
||||
"my-vision-model": "deepseek-v4-vision",
|
||||
}, "my-vision-model")
|
||||
if !ok || got != "deepseek-v4-vision-search" {
|
||||
t.Fatalf("expected alias -> deepseek-v4-vision-search, got ok=%v model=%q", ok, got)
|
||||
if !ok || got != "deepseek-v4-vision" {
|
||||
t.Fatalf("expected alias -> deepseek-v4-vision, got ok=%v model=%q", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelHeuristicVisionIgnoresSearchSuffix(t *testing.T) {
|
||||
got, ok := ResolveModel(nil, "gemini-vision-search")
|
||||
if !ok || got != "deepseek-v4-vision" {
|
||||
t.Fatalf("expected heuristic vision alias to resolve without search variant, got ok=%v model=%q", ok, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ var deepSeekBaseModels = []ModelInfo{
|
||||
{ID: "deepseek-v4-flash-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-v4-pro-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-v4-vision", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
{ID: "deepseek-v4-vision-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
|
||||
}
|
||||
|
||||
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels)
|
||||
@@ -67,7 +66,7 @@ func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
|
||||
switch baseModel {
|
||||
case "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-vision":
|
||||
return !noThinking, false, true
|
||||
case "deepseek-v4-flash-search", "deepseek-v4-pro-search", "deepseek-v4-vision-search":
|
||||
case "deepseek-v4-flash-search", "deepseek-v4-pro-search":
|
||||
return !noThinking, true, true
|
||||
default:
|
||||
return false, false, false
|
||||
@@ -81,7 +80,7 @@ func GetModelType(model string) (modelType string, ok bool) {
|
||||
return "default", true
|
||||
case "deepseek-v4-pro", "deepseek-v4-pro-search":
|
||||
return "expert", true
|
||||
case "deepseek-v4-vision", "deepseek-v4-vision-search":
|
||||
case "deepseek-v4-vision":
|
||||
return "vision", true
|
||||
default:
|
||||
return "", false
|
||||
@@ -359,8 +358,6 @@ func resolveCanonicalModel(aliases map[string]string, model string) (string, boo
|
||||
useSearch := strings.Contains(model, "search")
|
||||
|
||||
switch {
|
||||
case useVision && useSearch:
|
||||
return "deepseek-v4-vision-search", true
|
||||
case useVision:
|
||||
return "deepseek-v4-vision", true
|
||||
case useReasoner && useSearch:
|
||||
|
||||
@@ -30,9 +30,29 @@ func ResolvePath(envKey, defaultRel string) string {
|
||||
}
|
||||
|
||||
func ConfigPath() string {
|
||||
if strings.TrimSpace(os.Getenv("DS2API_CONFIG_PATH")) == "" && BaseDir() == "/app" {
|
||||
return containerDefaultConfigPath()
|
||||
}
|
||||
return ResolvePath("DS2API_CONFIG_PATH", "config.json")
|
||||
}
|
||||
|
||||
func containerDefaultConfigPath() string {
|
||||
// Container images run as non-root by default. Only use /data when mounted/provisioned.
|
||||
// Otherwise keep /app/config.json so admin-side save does not fail on MkdirAll("/data").
|
||||
if st, err := os.Stat("/data"); err == nil && st.IsDir() {
|
||||
return "/data/config.json"
|
||||
}
|
||||
return "/app/config.json"
|
||||
}
|
||||
|
||||
func legacyContainerConfigPath() string {
|
||||
return "/app/config.json"
|
||||
}
|
||||
|
||||
func shouldTryLegacyContainerConfigPath() bool {
|
||||
return strings.TrimSpace(os.Getenv("DS2API_CONFIG_PATH")) == "" && BaseDir() == "/app"
|
||||
}
|
||||
|
||||
func RawStreamSampleRoot() string {
|
||||
return ResolvePath("DS2API_RAW_STREAM_SAMPLE_ROOT", "tests/raw_stream_samples")
|
||||
}
|
||||
|
||||
28
internal/config/paths_test.go
Normal file
28
internal/config/paths_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainerDefaultConfigPath(t *testing.T) {
|
||||
t.Run("fallback to /app when /data is missing", func(t *testing.T) {
|
||||
// This test environment does not guarantee a writable/mounted /data.
|
||||
// If /data is absent we must keep /app fallback to avoid persistence failures.
|
||||
if _, err := os.Stat("/data"); err == nil {
|
||||
t.Skip("/data exists in this environment; cannot validate missing-/data fallback")
|
||||
}
|
||||
if got := containerDefaultConfigPath(); got != "/app/config.json" {
|
||||
t.Fatalf("containerDefaultConfigPath() = %q, want %q", got, "/app/config.json")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefer /data when /data directory exists", func(t *testing.T) {
|
||||
if _, err := os.Stat("/data"); err != nil {
|
||||
t.Skip("/data does not exist in this environment")
|
||||
}
|
||||
if got := containerDefaultConfigPath(); got != "/data/config.json" {
|
||||
t.Fatalf("containerDefaultConfigPath() = %q, want %q", got, "/data/config.json")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,12 +87,17 @@ func loadConfig() (Config, bool, error) {
|
||||
}
|
||||
return cfg, true, err
|
||||
}
|
||||
|
||||
cfg, err := loadConfigFromFile(ConfigPath())
|
||||
if err != nil {
|
||||
if shouldTryLegacyContainerConfigPath() {
|
||||
legacyPath := legacyContainerConfigPath()
|
||||
if legacyCfg, legacyErr := loadConfigFromFile(legacyPath); legacyErr == nil {
|
||||
Logger.Info("[config] loaded legacy container config path", "path", legacyPath)
|
||||
return legacyCfg, false, nil
|
||||
}
|
||||
}
|
||||
if IsVercel() {
|
||||
// Vercel one-click deploy may start without a writable/present config file.
|
||||
// Keep an in-memory config so users can bootstrap via WebUI then sync env.
|
||||
// Vercel may start without writable/present config; keep in-memory bootstrap config.
|
||||
return Config{}, true, nil
|
||||
}
|
||||
return Config{}, false, err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
dsprotocol "ds2api/internal/deepseek/protocol"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -27,7 +28,7 @@ type continueState struct {
|
||||
}
|
||||
|
||||
// wrapCompletionWithAutoContinue wraps the completion response body so that
|
||||
// if the upstream indicates the response is incomplete (WIP / INCOMPLETE /
|
||||
// if the upstream indicates the response is incomplete (INCOMPLETE /
|
||||
// AUTO_CONTINUE), ds2api will automatically call the DeepSeek continue
|
||||
// endpoint and splice the continuation SSE stream onto the original.
|
||||
// The caller sees a single, seamless SSE stream.
|
||||
@@ -176,12 +177,12 @@ func (s *continueState) observe(data string) {
|
||||
}
|
||||
// Path-based status: {"p": "response/status", "v": "FINISHED"}
|
||||
if p, _ := chunk["p"].(string); p == "response/status" {
|
||||
if status, _ := chunk["v"].(string); status != "" {
|
||||
s.lastStatus = strings.TrimSpace(status)
|
||||
if strings.EqualFold(s.lastStatus, "FINISHED") {
|
||||
s.finished = true
|
||||
}
|
||||
}
|
||||
s.setStatus(asString(chunk["v"]))
|
||||
}
|
||||
if p, _ := chunk["p"].(string); p == "response" {
|
||||
s.observeBatchPatches("response", chunk["v"])
|
||||
} else {
|
||||
s.observeBatchPatches("", chunk["v"])
|
||||
}
|
||||
// Nested v.response
|
||||
v, _ := chunk["v"].(map[string]any)
|
||||
@@ -189,12 +190,7 @@ func (s *continueState) observe(data string) {
|
||||
if id := intFrom(response["message_id"]); id > 0 {
|
||||
s.responseMessageID = id
|
||||
}
|
||||
if status, _ := response["status"].(string); status != "" {
|
||||
s.lastStatus = strings.TrimSpace(status)
|
||||
if strings.EqualFold(s.lastStatus, "FINISHED") {
|
||||
s.finished = true
|
||||
}
|
||||
}
|
||||
s.setStatus(asString(response["status"]))
|
||||
if autoContinue, ok := response["auto_continue"].(bool); ok && autoContinue {
|
||||
s.lastStatus = "AUTO_CONTINUE"
|
||||
}
|
||||
@@ -205,18 +201,56 @@ func (s *continueState) observe(data string) {
|
||||
if id := intFrom(response["message_id"]); id > 0 {
|
||||
s.responseMessageID = id
|
||||
}
|
||||
if status, _ := response["status"].(string); status != "" {
|
||||
s.lastStatus = strings.TrimSpace(status)
|
||||
if strings.EqualFold(s.lastStatus, "FINISHED") {
|
||||
s.finished = true
|
||||
}
|
||||
}
|
||||
s.setStatus(asString(response["status"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldContinue returns true when the upstream indicates the response is
|
||||
// not yet finished and we have enough information to issue a continue request.
|
||||
func (s *continueState) observeBatchPatches(parentPath string, raw any) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
patches, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, patch := range patches {
|
||||
m, ok := patch.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(asString(m["p"]))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fullPath := path
|
||||
if parent := strings.Trim(strings.TrimSpace(parentPath), "/"); parent != "" && !strings.Contains(path, "/") {
|
||||
fullPath = parent + "/" + path
|
||||
}
|
||||
switch strings.Trim(strings.TrimSpace(fullPath), "/") {
|
||||
case "response/status", "status", "response/quasi_status", "quasi_status":
|
||||
s.setStatus(asString(m["v"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *continueState) setStatus(status string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
normalized := strings.TrimSpace(status)
|
||||
if normalized == "" {
|
||||
return
|
||||
}
|
||||
s.lastStatus = normalized
|
||||
if strings.EqualFold(normalized, "FINISHED") || strings.EqualFold(normalized, "CONTENT_FILTER") {
|
||||
s.finished = true
|
||||
}
|
||||
}
|
||||
|
||||
// shouldContinue returns true when the upstream explicitly indicates the
|
||||
// response is incomplete and we have enough information to issue a continue
|
||||
// request. Plain WIP is not sufficient because normal streams begin in WIP.
|
||||
func (s *continueState) shouldContinue() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
@@ -225,7 +259,7 @@ func (s *continueState) shouldContinue() bool {
|
||||
return false
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(s.lastStatus)) {
|
||||
case "WIP", "INCOMPLETE", "AUTO_CONTINUE":
|
||||
case "INCOMPLETE", "AUTO_CONTINUE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -241,3 +275,19 @@ func (s *continueState) prepareForNextRound() {
|
||||
s.finished = false
|
||||
s.lastStatus = ""
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
default:
|
||||
s := strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(fmt.Sprint(v)), "\u0000", ""))
|
||||
if s == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/auth"
|
||||
@@ -124,6 +125,90 @@ func TestCallCompletionAutoContinueThreadsPowHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoContinueDoesNotTriggerOnPlainWIPWithoutExplicitContinuationSignal(t *testing.T) {
|
||||
initialBody := strings.Join([]string{
|
||||
`data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":false}}}`,
|
||||
`data: [DONE]`,
|
||||
}, "\n") + "\n"
|
||||
|
||||
var continueCalls atomic.Int32
|
||||
body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) {
|
||||
continueCalls.Add(1)
|
||||
return nil, errors.New("continue should not have been called")
|
||||
})
|
||||
defer func() { _ = body.Close() }()
|
||||
|
||||
out, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if continueCalls.Load() != 0 {
|
||||
t.Fatalf("expected no continue calls, got %d", continueCalls.Load())
|
||||
}
|
||||
if !bytes.Contains(out, []byte(`"status":"WIP"`)) || !bytes.Contains(out, []byte(`data: [DONE]`)) {
|
||||
t.Fatalf("expected original body to pass through unchanged, got=%s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoContinueTriggersOnResponseBatchQuasiStatusIncomplete(t *testing.T) {
|
||||
initialBody := strings.Join([]string{
|
||||
`data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":false}}}`,
|
||||
`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":2413},{"p":"quasi_status","v":"INCOMPLETE"}]}`,
|
||||
`data: [DONE]`,
|
||||
}, "\n") + "\n"
|
||||
|
||||
var continueCalls atomic.Int32
|
||||
body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) {
|
||||
continueCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`data: {"response_message_id":322,"p":"response/status","v":"FINISHED"}` + "\n" +
|
||||
`data: [DONE]` + "\n",
|
||||
)),
|
||||
}, nil
|
||||
})
|
||||
defer func() { _ = body.Close() }()
|
||||
|
||||
out, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if continueCalls.Load() != 1 {
|
||||
t.Fatalf("expected exactly one continue call, got %d", continueCalls.Load())
|
||||
}
|
||||
if !bytes.Contains(out, []byte(`"quasi_status","v":"INCOMPLETE"`)) || !bytes.Contains(out, []byte(`"v":"FINISHED"`)) {
|
||||
t.Fatalf("expected continued output to include initial and final rounds, got=%s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoContinueDoesNotTriggerWhenResponseBatchQuasiStatusFinished(t *testing.T) {
|
||||
initialBody := strings.Join([]string{
|
||||
`data: {"response_message_id":321,"v":{"response":{"message_id":321,"status":"WIP","auto_continue":false}}}`,
|
||||
`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":2413},{"p":"quasi_status","v":"FINISHED"}]}`,
|
||||
`data: [DONE]`,
|
||||
}, "\n") + "\n"
|
||||
|
||||
var continueCalls atomic.Int32
|
||||
body := newAutoContinueBody(context.Background(), io.NopCloser(strings.NewReader(initialBody)), "session-123", 8, func(context.Context, string, int) (*http.Response, error) {
|
||||
continueCalls.Add(1)
|
||||
return nil, errors.New("continue should not have been called")
|
||||
})
|
||||
defer func() { _ = body.Close() }()
|
||||
|
||||
out, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body failed: %v", err)
|
||||
}
|
||||
if continueCalls.Load() != 0 {
|
||||
t.Fatalf("expected no continue calls, got %d", continueCalls.Load())
|
||||
}
|
||||
if !bytes.Contains(out, []byte(`"quasi_status","v":"FINISHED"`)) || !bytes.Contains(out, []byte(`data: [DONE]`)) {
|
||||
t.Fatalf("expected original finished body to pass through unchanged, got=%s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
type failingOrCompletionDoer struct {
|
||||
completionResp *http.Response
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/util"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -194,7 +196,8 @@ func (c *captureBody) append(chunk string) {
|
||||
}
|
||||
remain := maxLen - current
|
||||
if len(chunk) > remain {
|
||||
c.buf.WriteString(chunk[:remain])
|
||||
truncated, _ := util.TruncateUTF8Bytes(chunk, remain)
|
||||
c.buf.WriteString(truncated)
|
||||
c.truncated = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestNewFromEnvDefaults(t *testing.T) {
|
||||
@@ -82,3 +83,28 @@ func TestWrapBodyTruncatesByLimit(t *testing.T) {
|
||||
t.Fatalf("expected account id, got %q", items[0].AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapBodyTruncatesUTF8WithoutBreakingRune(t *testing.T) {
|
||||
s := &Store{enabled: true, limit: 5, maxBodyBytes: 5}
|
||||
session := s.Start("test", "http://x", "acc1", map[string]any{"x": 1})
|
||||
if session == nil {
|
||||
t.Fatal("expected session")
|
||||
}
|
||||
rc := session.WrapBody(io.NopCloser(strings.NewReader("😀xy")), 200)
|
||||
_, _ = io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
|
||||
items := s.Snapshot()
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if !utf8.ValidString(items[0].ResponseBody) {
|
||||
t.Fatalf("expected valid utf-8 response body, got %q", items[0].ResponseBody)
|
||||
}
|
||||
if items[0].ResponseBody != "😀x" {
|
||||
t.Fatalf("expected rune-safe truncation, got %q", items[0].ResponseBody)
|
||||
}
|
||||
if !items[0].ResponseTruncated {
|
||||
t.Fatal("expected truncated flag true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
"model": model,
|
||||
"session_count": 0,
|
||||
"config_writable": !h.Store.IsEnvBacked(),
|
||||
"config_warning": "",
|
||||
}
|
||||
defer func() {
|
||||
status := "failed"
|
||||
@@ -121,8 +122,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
return result
|
||||
}
|
||||
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
|
||||
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
result["config_warning"] = "登录成功,但 token 持久化失败(仅保存在内存,重启后会丢失): " + err.Error()
|
||||
}
|
||||
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
|
||||
proxyCtx := authn.WithAuth(ctx, authCtx)
|
||||
@@ -136,8 +136,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
token = newToken
|
||||
authCtx.DeepSeekToken = token
|
||||
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
|
||||
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
|
||||
return result
|
||||
result["config_warning"] = "刷新 token 成功,但 token 持久化失败(仅保存在内存,重启后会丢失): " + err.Error()
|
||||
}
|
||||
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
|
||||
if err != nil {
|
||||
@@ -155,6 +154,9 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
if strings.TrimSpace(message) == "" {
|
||||
result["success"] = true
|
||||
result["message"] = "Token 刷新成功(登录与会话创建成功)"
|
||||
if warning, _ := result["config_warning"].(string); strings.TrimSpace(warning) != "" {
|
||||
result["message"] = result["message"].(string) + ";" + warning
|
||||
}
|
||||
result["response_time"] = int(time.Since(start).Milliseconds())
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"ds2api/internal/devcapture"
|
||||
adminshared "ds2api/internal/httpapi/admin/shared"
|
||||
"ds2api/internal/rawsample"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type captureChain struct {
|
||||
@@ -479,10 +480,13 @@ func previewCaptureChainResponse(chain captureChain) string {
|
||||
|
||||
func previewText(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 || len(text) <= limit {
|
||||
if limit <= 0 {
|
||||
return text
|
||||
}
|
||||
return text[:limit] + "..."
|
||||
if truncated, ok := util.TruncateRunes(text, limit); ok {
|
||||
return truncated + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func captureChainHasTruncatedResponse(chain captureChain) bool {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"ds2api/internal/devcapture"
|
||||
)
|
||||
@@ -231,6 +232,16 @@ func TestCombineCaptureBodiesPreservesOrderAndSeparators(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewTextPreservesUTF8MB4Characters(t *testing.T) {
|
||||
preview := previewText(strings.Repeat("😀", 281), 280)
|
||||
if !utf8.ValidString(preview) {
|
||||
t.Fatalf("expected valid utf-8 preview, got %q", preview)
|
||||
}
|
||||
if preview != strings.Repeat("😀", 280)+"..." {
|
||||
t.Fatalf("unexpected preview: %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRawSampleCapturesGroupsBySessionAndMatchesQuestion(t *testing.T) {
|
||||
devcapture.Global().Clear()
|
||||
defer devcapture.Global().Clear()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -31,30 +32,9 @@ func extractClaudeToolNames(tools []any) []string {
|
||||
}
|
||||
|
||||
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
|
||||
name, _ := m["name"].(string)
|
||||
desc, _ := m["description"].(string)
|
||||
schemaObj := m["input_schema"]
|
||||
if schemaObj == nil {
|
||||
schemaObj = m["parameters"]
|
||||
}
|
||||
|
||||
if fn, ok := m["function"].(map[string]any); ok {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name, _ = fn["name"].(string)
|
||||
}
|
||||
if strings.TrimSpace(desc) == "" {
|
||||
desc, _ = fn["description"].(string)
|
||||
}
|
||||
if schemaObj == nil {
|
||||
if v, ok := fn["input_schema"]; ok {
|
||||
schemaObj = v
|
||||
}
|
||||
}
|
||||
if schemaObj == nil {
|
||||
if v, ok := fn["parameters"]; ok {
|
||||
schemaObj = v
|
||||
}
|
||||
}
|
||||
name, desc, schemaObj := toolcall.ExtractToolMeta(m)
|
||||
if strings.TrimSpace(desc) == "" {
|
||||
desc = "No description available"
|
||||
}
|
||||
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func stripClaudeThinkingBlocks(raw []byte) []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string, toolsRaw any) {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
@@ -205,6 +205,7 @@ func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Requ
|
||||
searchEnabled,
|
||||
h.compatStripReferenceMarkers(),
|
||||
toolNames,
|
||||
toolsRaw,
|
||||
)
|
||||
streamRuntime.sendMessageStart()
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestHandleClaudeStreamRealtimeTextIncrementsWithEventHeaders(t *testing.T)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "event: message_start") {
|
||||
@@ -122,7 +122,7 @@ func TestHandleClaudeStreamRealtimeThinkingDelta(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil)
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, true, false, nil, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundThinkingDelta := false
|
||||
@@ -149,7 +149,7 @@ func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *t
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
for _, f := range findClaudeFrames(frames, "content_block_start") {
|
||||
@@ -180,7 +180,7 @@ func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
errFrames := findClaudeFrames(frames, "error")
|
||||
@@ -217,7 +217,7 @@ func TestHandleClaudeStreamRealtimePingEvent(t *testing.T) {
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil)
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "hi"}}, false, false, nil, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
if len(findClaudeFrames(frames, "ping")) == 0 {
|
||||
@@ -271,7 +271,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"})
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"Bash"}, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
@@ -299,7 +299,7 @@ func TestHandleClaudeStreamRealtimeDetectsToolUseWithLeadingProse(t *testing.T)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"})
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, false, false, []string{"write_file"}, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
@@ -333,7 +333,7 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}, nil)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
foundToolUse := false
|
||||
@@ -365,3 +365,48 @@ func TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t *testing.T
|
||||
func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
|
||||
TestHandleClaudeStreamRealtimeIgnoresUnclosedFencedToolExample(t)
|
||||
}
|
||||
|
||||
func TestHandleClaudeStreamRealtimeNormalizesToolInputBySchema(t *testing.T) {
|
||||
h := &Handler{}
|
||||
resp := makeClaudeSSEHTTPResponse(
|
||||
`data: {"p":"response/content","v":"<tool_calls><invoke name=\"Write\">{\"input\":{\"content\":{\"message\":\"hi\"},\"taskId\":1}}</invoke></tool_calls>"}`,
|
||||
`data: [DONE]`,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
|
||||
toolsRaw := []any{
|
||||
map[string]any{
|
||||
"name": "Write",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
"taskId": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "write"}}, false, false, []string{"Write"}, toolsRaw)
|
||||
|
||||
frames := parseClaudeFrames(t, rec.Body.String())
|
||||
for _, f := range findClaudeFrames(frames, "content_block_delta") {
|
||||
delta, _ := f.Payload["delta"].(map[string]any)
|
||||
if delta["type"] != "input_json_delta" {
|
||||
continue
|
||||
}
|
||||
partial := asString(delta["partial_json"])
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(partial), &args); err != nil {
|
||||
t.Fatalf("decode partial_json failed: %v payload=%s", err, partial)
|
||||
}
|
||||
if args["content"] != `{"message":"hi"}` {
|
||||
t.Fatalf("expected content normalized to string, got %#v", args["content"])
|
||||
}
|
||||
if args["taskId"] != "1" {
|
||||
t.Fatalf("expected taskId normalized to string, got %#v", args["taskId"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("expected input_json_delta frame, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
|
||||
ResolvedModel: dsModel,
|
||||
ResponseModel: strings.TrimSpace(model),
|
||||
Messages: payload["messages"].([]any),
|
||||
ToolsRaw: toolsRequested,
|
||||
FinalPrompt: finalPrompt,
|
||||
ToolNames: toolNames,
|
||||
Stream: util.ToBool(req["stream"]),
|
||||
|
||||
@@ -32,11 +32,39 @@ func TestNormalizeClaudeRequest(t *testing.T) {
|
||||
if len(norm.Standard.ToolNames) == 0 {
|
||||
t.Fatalf("expected tool names")
|
||||
}
|
||||
if norm.Standard.ToolsRaw == nil {
|
||||
t.Fatalf("expected ToolsRaw preserved for downstream normalization")
|
||||
}
|
||||
if norm.Standard.FinalPrompt == "" {
|
||||
t.Fatalf("expected non-empty final prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClaudeRequestSupportsCamelCaseInputSchemaPromptInjection(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{}`)
|
||||
store := config.LoadStore()
|
||||
req := map[string]any{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "user", "content": "hello"},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{
|
||||
"name": "todowrite",
|
||||
"description": "Write todos",
|
||||
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{"todos": map[string]any{"type": "array"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
norm, err := normalizeClaudeRequest(store, req)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
if !containsStr(norm.Standard.FinalPrompt, `"type":"array"`) {
|
||||
t.Fatalf("expected inputSchema to be injected into prompt, got=%q", norm.Standard.FinalPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeClaudeRequestInjectsToolsIntoExistingSystemMessage(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{}`)
|
||||
store := config.LoadStore()
|
||||
|
||||
@@ -18,6 +18,7 @@ type claudeStreamRuntime struct {
|
||||
model string
|
||||
toolNames []string
|
||||
messages []any
|
||||
toolsRaw any
|
||||
|
||||
thinkingEnabled bool
|
||||
searchEnabled bool
|
||||
@@ -47,6 +48,7 @@ func newClaudeStreamRuntime(
|
||||
searchEnabled bool,
|
||||
stripReferenceMarkers bool,
|
||||
toolNames []string,
|
||||
toolsRaw any,
|
||||
) *claudeStreamRuntime {
|
||||
return &claudeStreamRuntime{
|
||||
w: w,
|
||||
@@ -59,6 +61,7 @@ func newClaudeStreamRuntime(
|
||||
bufferToolContent: len(toolNames) > 0,
|
||||
stripReferenceMarkers: stripReferenceMarkers,
|
||||
toolNames: toolNames,
|
||||
toolsRaw: toolsRaw,
|
||||
messageID: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||
thinkingBlockIndex: -1,
|
||||
textBlockIndex: -1,
|
||||
|
||||
@@ -52,6 +52,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
|
||||
detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames)
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
detected = toolcall.NormalizeParsedToolCallsForSchemas(detected, s.toolsRaw)
|
||||
stopReason = "tool_use"
|
||||
for i, tc := range detected {
|
||||
idx := s.nextBlockIndex + i
|
||||
|
||||
@@ -307,14 +307,14 @@ func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected detail item, got %v", err)
|
||||
}
|
||||
if full.HistoryText != "" {
|
||||
t.Fatalf("expected current input file flow to leave history text empty, got %q", full.HistoryText)
|
||||
}
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
if ds.uploadCalls[0].Filename != "IGNORE.txt" {
|
||||
t.Fatalf("expected IGNORE.txt upload, got %q", ds.uploadCalls[0].Filename)
|
||||
if ds.uploadCalls[0].Filename != "history.txt" {
|
||||
t.Fatalf("expected history.txt upload, got %q", ds.uploadCalls[0].Filename)
|
||||
}
|
||||
if full.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||
t.Fatalf("expected uploaded current input file to be persisted in history text")
|
||||
}
|
||||
if len(full.Messages) != 1 {
|
||||
t.Fatalf("expected neutral prompt to be the only persisted message, got %#v", full.Messages)
|
||||
|
||||
@@ -36,8 +36,10 @@ type chatStreamRuntime struct {
|
||||
toolSieve toolstream.State
|
||||
streamToolCallIDs map[int]string
|
||||
streamToolNames map[int]string
|
||||
rawThinking strings.Builder
|
||||
thinking strings.Builder
|
||||
toolDetectionThinking strings.Builder
|
||||
rawText strings.Builder
|
||||
text strings.Builder
|
||||
responseMessageID int
|
||||
|
||||
@@ -141,7 +143,7 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool)
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
s.finalThinking = finalThinking
|
||||
s.finalText = finalText
|
||||
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
|
||||
detected := detectAssistantToolCalls(s.rawText.String(), finalText, s.rawThinking.String(), finalToolDetectionThinking, s.toolNames)
|
||||
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
|
||||
finishReason = "tool_calls"
|
||||
delta := map[string]any{
|
||||
@@ -186,7 +188,7 @@ func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool)
|
||||
continue
|
||||
}
|
||||
cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers)
|
||||
if cleaned == "" {
|
||||
if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) {
|
||||
continue
|
||||
}
|
||||
delta := map[string]any{
|
||||
@@ -263,21 +265,22 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
|
||||
}
|
||||
}
|
||||
for _, p := range parsed.Parts {
|
||||
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
|
||||
if s.searchEnabled && sse.IsCitation(cleanedText) {
|
||||
continue
|
||||
}
|
||||
if cleanedText == "" {
|
||||
continue
|
||||
}
|
||||
contentSeen = true
|
||||
delta := map[string]any{}
|
||||
if !s.firstChunkSent {
|
||||
delta["role"] = "assistant"
|
||||
s.firstChunkSent = true
|
||||
}
|
||||
if p.Type == "thinking" {
|
||||
rawTrimmed := sse.TrimContinuationOverlap(s.rawThinking.String(), p.Text)
|
||||
if rawTrimmed != "" {
|
||||
s.rawThinking.WriteString(rawTrimmed)
|
||||
contentSeen = true
|
||||
}
|
||||
if s.thinkingEnabled {
|
||||
cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers)
|
||||
if cleanedText == "" {
|
||||
continue
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
@@ -286,15 +289,27 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
|
||||
delta["reasoning_content"] = trimmed
|
||||
}
|
||||
} else {
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
rawTrimmed := sse.TrimContinuationOverlap(s.rawText.String(), p.Text)
|
||||
if rawTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(trimmed)
|
||||
s.rawText.WriteString(rawTrimmed)
|
||||
contentSeen = true
|
||||
cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers)
|
||||
if s.searchEnabled && sse.IsCitation(cleanedText) {
|
||||
continue
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed != "" {
|
||||
s.text.WriteString(trimmed)
|
||||
}
|
||||
if !s.bufferToolContent {
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
delta["content"] = trimmed
|
||||
} else {
|
||||
events := toolstream.ProcessChunk(&s.toolSieve, trimmed, s.toolNames)
|
||||
events := toolstream.ProcessChunk(&s.toolSieve, rawTrimmed, s.toolNames)
|
||||
for _, evt := range events {
|
||||
if len(evt.ToolCallDeltas) > 0 {
|
||||
if !s.emitEarlyToolDeltas {
|
||||
@@ -335,7 +350,7 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
|
||||
}
|
||||
if evt.Content != "" {
|
||||
cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers)
|
||||
if cleaned == "" {
|
||||
if cleaned == "" || (s.searchEnabled && sse.IsCitation(cleaned)) {
|
||||
continue
|
||||
}
|
||||
contentDelta := map[string]any{
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
)
|
||||
|
||||
type chatNonStreamResult struct {
|
||||
rawThinking string
|
||||
rawText string
|
||||
thinking string
|
||||
toolDetectionThinking string
|
||||
text string
|
||||
@@ -31,6 +33,7 @@ func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Co
|
||||
currentResp := resp
|
||||
usagePrompt := finalPrompt
|
||||
accumulatedThinking := ""
|
||||
accumulatedRawThinking := ""
|
||||
accumulatedToolDetectionThinking := ""
|
||||
for {
|
||||
result, ok := h.collectChatNonStreamAttempt(w, currentResp, completionID, model, usagePrompt, thinkingEnabled, searchEnabled, toolNames, toolsRaw)
|
||||
@@ -38,10 +41,12 @@ func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Co
|
||||
return
|
||||
}
|
||||
accumulatedThinking += sse.TrimContinuationOverlap(accumulatedThinking, result.thinking)
|
||||
accumulatedRawThinking += sse.TrimContinuationOverlap(accumulatedRawThinking, result.rawThinking)
|
||||
accumulatedToolDetectionThinking += sse.TrimContinuationOverlap(accumulatedToolDetectionThinking, result.toolDetectionThinking)
|
||||
result.thinking = accumulatedThinking
|
||||
result.rawThinking = accumulatedRawThinking
|
||||
result.toolDetectionThinking = accumulatedToolDetectionThinking
|
||||
detected := detectAssistantToolCalls(result.text, result.thinking, result.toolDetectionThinking, toolNames)
|
||||
detected := detectAssistantToolCalls(result.rawText, result.text, result.rawThinking, result.toolDetectionThinking, toolNames)
|
||||
result.detectedCalls = len(detected.Calls)
|
||||
result.body = openaifmt.BuildChatCompletionWithToolCalls(completionID, model, usagePrompt, result.thinking, result.text, detected.Calls, toolsRaw)
|
||||
result.finishReason = chatFinishReason(result.body)
|
||||
@@ -82,16 +87,17 @@ func (h *Handler) collectChatNonStreamAttempt(w http.ResponseWriter, resp *http.
|
||||
result := sse.CollectStream(resp, thinkingEnabled, true)
|
||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||
finalToolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
|
||||
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||
if searchEnabled {
|
||||
finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks)
|
||||
}
|
||||
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, toolNames)
|
||||
detected := detectAssistantToolCalls(result.Text, finalText, result.Thinking, result.ToolDetectionThinking, toolNames)
|
||||
respBody := openaifmt.BuildChatCompletionWithToolCalls(completionID, model, usagePrompt, finalThinking, finalText, detected.Calls, toolsRaw)
|
||||
return chatNonStreamResult{
|
||||
rawThinking: result.Thinking,
|
||||
rawText: result.Text,
|
||||
thinking: finalThinking,
|
||||
toolDetectionThinking: finalToolDetectionThinking,
|
||||
toolDetectionThinking: result.ToolDetectionThinking,
|
||||
text: finalText,
|
||||
contentFilter: result.ContentFilter,
|
||||
detectedCalls: len(detected.Calls),
|
||||
|
||||
@@ -148,6 +148,6 @@ func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, id
|
||||
return shared.FormatFinalStreamToolCallsWithStableIDs(calls, ids, toolsRaw)
|
||||
}
|
||||
|
||||
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
|
||||
func detectAssistantToolCalls(rawText, visibleText, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
return shared.DetectAssistantToolCalls(rawText, visibleText, exposedThinking, detectionThinking, toolNames)
|
||||
}
|
||||
|
||||
@@ -162,12 +162,11 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co
|
||||
|
||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||
finalToolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
|
||||
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||
if searchEnabled {
|
||||
finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks)
|
||||
}
|
||||
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, toolNames)
|
||||
detected := detectAssistantToolCalls(result.Text, finalText, result.Thinking, result.ToolDetectionThinking, toolNames)
|
||||
if shouldWriteUpstreamEmptyOutputError(finalText) && len(detected.Calls) == 0 {
|
||||
status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking)
|
||||
if historySession != nil {
|
||||
|
||||
@@ -291,20 +291,16 @@ func TestHandleStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstreamIntercep
|
||||
if !streamHasToolCallsDelta(frames) {
|
||||
t.Fatalf("expected tool_calls delta from finalize fallback, body=%s", rec.Body.String())
|
||||
}
|
||||
reasoningSeen := false
|
||||
for _, frame := range frames {
|
||||
choices, _ := frame["choices"].([]any)
|
||||
for _, item := range choices {
|
||||
choice, _ := item.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if asString(delta["reasoning_content"]) != "" {
|
||||
reasoningSeen = true
|
||||
t.Fatalf("did not expect leaked reasoning_content markup, body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reasoningSeen {
|
||||
t.Fatalf("expected reasoning_content to stream before finalize fallback, body=%s", rec.Body.String())
|
||||
}
|
||||
if streamFinishReason(frames) != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -54,3 +54,31 @@ func TestReplaceCitationMarkersWithLinksSupportsReferenceZeroBased(t *testing.T)
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCitationMarkersWithLinksKeepsCitationOneBasedWithZeroBasedReference(t *testing.T) {
|
||||
raw := "引用[citation:1],来源[reference:0],后续[reference:1]。"
|
||||
links := map[int]string{
|
||||
1: "https://example.com/first",
|
||||
2: "https://example.com/second",
|
||||
}
|
||||
|
||||
got := replaceCitationMarkersWithLinks(raw, links)
|
||||
want := "引用[1](https://example.com/first),来源[0](https://example.com/first),后续[1](https://example.com/second)。"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCitationMarkersWithLinksDetectsSpacedReferenceZeroBased(t *testing.T) {
|
||||
raw := "来源[reference: 0] 与 [reference: 1]。"
|
||||
links := map[int]string{
|
||||
1: "https://example.com/first",
|
||||
2: "https://example.com/second",
|
||||
}
|
||||
|
||||
got := replaceCitationMarkersWithLinks(raw, links)
|
||||
want := "来源[0](https://example.com/first) 与 [1](https://example.com/second)。"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
currentInputFilename = "IGNORE.txt"
|
||||
currentInputFilename = promptcompat.CurrentInputContextFilename
|
||||
currentInputContentType = "text/plain; charset=utf-8"
|
||||
currentInputPurpose = "assistants"
|
||||
)
|
||||
@@ -58,6 +58,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
|
||||
}
|
||||
|
||||
stdReq.Messages = messages
|
||||
stdReq.HistoryText = fileText
|
||||
stdReq.CurrentInputFileApplied = true
|
||||
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
|
||||
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
|
||||
|
||||
@@ -64,8 +64,8 @@ func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *test
|
||||
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
|
||||
transcript := buildOpenAICurrentInputContextTranscript(historyMessages)
|
||||
|
||||
if !strings.HasPrefix(transcript, "[file content end]\n\n") {
|
||||
t.Fatalf("expected injected file wrapper prefix, got %q", transcript)
|
||||
if strings.Contains(transcript, "[file content end]") || strings.Contains(transcript, "[file content begin]") || strings.Contains(transcript, "[file name]:") {
|
||||
t.Fatalf("expected plain transcript without file wrapper tags, got %q", transcript)
|
||||
}
|
||||
if !strings.Contains(transcript, "<|begin▁of▁sentence|>") {
|
||||
t.Fatalf("expected serialized conversation markers, got %q", transcript)
|
||||
@@ -79,9 +79,7 @@ func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *test
|
||||
if !strings.Contains(transcript, "<|DSML|tool_calls>") {
|
||||
t.Fatalf("expected tool calls preserved, got %q", transcript)
|
||||
}
|
||||
if !strings.HasSuffix(transcript, "\n[file name]: IGNORE\n[file content begin]\n") {
|
||||
t.Fatalf("expected injected file wrapper suffix, got %q", transcript)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
|
||||
@@ -274,12 +272,12 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
||||
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
upload := ds.uploadCalls[0]
|
||||
if upload.Filename != "IGNORE.txt" {
|
||||
if upload.Filename != "history.txt" {
|
||||
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
||||
}
|
||||
uploadedText := string(upload.Data)
|
||||
if !strings.HasPrefix(uploadedText, "[file content end]\n\n") {
|
||||
t.Fatalf("expected injected file wrapper prefix, got %q", uploadedText)
|
||||
if strings.Contains(uploadedText, "[file content end]") || strings.Contains(uploadedText, "[file content begin]") || strings.Contains(uploadedText, "[file name]:") {
|
||||
t.Fatalf("expected uploaded transcript without file wrapper tags, got %q", uploadedText)
|
||||
}
|
||||
if !strings.Contains(uploadedText, "<|begin▁of▁sentence|><|User|>first turn content that is long enough") {
|
||||
t.Fatalf("expected serialized current user turn markers, got %q", uploadedText)
|
||||
@@ -287,13 +285,11 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
||||
if !strings.Contains(uploadedText, promptcompat.ThinkingInjectionMarker) {
|
||||
t.Fatalf("expected thinking injection in current input file, got %q", uploadedText)
|
||||
}
|
||||
if !strings.HasSuffix(uploadedText, "\n[file name]: IGNORE\n[file content begin]\n") {
|
||||
t.Fatalf("expected injected file wrapper suffix, got %q", uploadedText)
|
||||
}
|
||||
|
||||
if strings.Contains(out.FinalPrompt, "first turn content that is long enough") {
|
||||
t.Fatalf("expected current input text to be replaced in live prompt, got %s", out.FinalPrompt)
|
||||
}
|
||||
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "IGNORE.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
|
||||
}
|
||||
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
|
||||
@@ -335,8 +331,8 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
||||
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
upload := ds.uploadCalls[0]
|
||||
if upload.Filename != "IGNORE.txt" {
|
||||
t.Fatalf("expected IGNORE.txt upload, got %q", upload.Filename)
|
||||
if upload.Filename != "history.txt" {
|
||||
t.Fatalf("expected history.txt upload, got %q", upload.Filename)
|
||||
}
|
||||
uploadedText := string(upload.Data)
|
||||
for _, want := range []string{"system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
|
||||
@@ -344,7 +340,7 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
||||
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "IGNORE.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||
t.Fatalf("expected live prompt to use only a neutral continuation instruction, got %s", out.FinalPrompt)
|
||||
}
|
||||
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
|
||||
@@ -352,7 +348,7 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
|
||||
func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) {
|
||||
ds := &inlineUploadDSStub{}
|
||||
h := &openAITestSurface{
|
||||
Store: mockOpenAIConfig{
|
||||
@@ -377,8 +373,8 @@ func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
|
||||
if len(ds.uploadCalls) != 1 {
|
||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
if out.HistoryText != "" {
|
||||
t.Fatalf("expected current input file flow to leave history text empty, got %q", out.HistoryText)
|
||||
if out.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,15 +407,15 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t
|
||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||
}
|
||||
upload := ds.uploadCalls[0]
|
||||
if upload.Filename != "IGNORE.txt" {
|
||||
if upload.Filename != "history.txt" {
|
||||
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
||||
}
|
||||
if upload.Purpose != "assistants" {
|
||||
t.Fatalf("unexpected purpose: %q", upload.Purpose)
|
||||
}
|
||||
historyText := string(upload.Data)
|
||||
if !strings.Contains(historyText, "[file content end]") || !strings.Contains(historyText, "[file name]: IGNORE") {
|
||||
t.Fatalf("expected injected IGNORE wrapper, got %s", historyText)
|
||||
if strings.Contains(historyText, "[file content end]") || strings.Contains(historyText, "[file content begin]") || strings.Contains(historyText, "[file name]:") {
|
||||
t.Fatalf("expected plain history transcript without wrapper tags, got %s", historyText)
|
||||
}
|
||||
if !strings.Contains(historyText, "latest user turn") {
|
||||
t.Fatalf("expected full context to include latest turn, got %s", historyText)
|
||||
|
||||
@@ -42,6 +42,14 @@ func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedOutputRemovesCompleteDSMLToolCallWrapper(t *testing.T) {
|
||||
raw := "前置文本\n<|DSML|tool_calls>\n<|DSML|invoke name=\"Bash\">\n<|DSML|parameter name=\"command\"></|DSML|parameter>\n</|DSML|invoke>\n</|DSML|tool_calls>\n后置文本"
|
||||
got := sanitizeLeakedOutput(raw)
|
||||
if got != "前置文本\n\n后置文本" {
|
||||
t.Fatalf("unexpected sanitize result for leaked dsml wrapper: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedOutputRemovesAgentXMLLeaks(t *testing.T) {
|
||||
raw := "Done.<attempt_completion><result>Some final answer</result></attempt_completion>"
|
||||
got := sanitizeLeakedOutput(raw)
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
)
|
||||
|
||||
type responsesNonStreamResult struct {
|
||||
rawThinking string
|
||||
rawText string
|
||||
thinking string
|
||||
toolDetectionThinking string
|
||||
text string
|
||||
@@ -32,6 +34,7 @@ func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx c
|
||||
currentResp := resp
|
||||
usagePrompt := finalPrompt
|
||||
accumulatedThinking := ""
|
||||
accumulatedRawThinking := ""
|
||||
accumulatedToolDetectionThinking := ""
|
||||
for {
|
||||
result, ok := h.collectResponsesNonStreamAttempt(w, currentResp, responseID, model, usagePrompt, thinkingEnabled, searchEnabled, toolNames, toolsRaw)
|
||||
@@ -39,10 +42,12 @@ func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx c
|
||||
return
|
||||
}
|
||||
accumulatedThinking += sse.TrimContinuationOverlap(accumulatedThinking, result.thinking)
|
||||
accumulatedRawThinking += sse.TrimContinuationOverlap(accumulatedRawThinking, result.rawThinking)
|
||||
accumulatedToolDetectionThinking += sse.TrimContinuationOverlap(accumulatedToolDetectionThinking, result.toolDetectionThinking)
|
||||
result.thinking = accumulatedThinking
|
||||
result.rawThinking = accumulatedRawThinking
|
||||
result.toolDetectionThinking = accumulatedToolDetectionThinking
|
||||
result.parsed = detectAssistantToolCalls(result.text, result.thinking, result.toolDetectionThinking, toolNames)
|
||||
result.parsed = detectAssistantToolCalls(result.rawText, result.text, result.rawThinking, result.toolDetectionThinking, toolNames)
|
||||
result.body = openaifmt.BuildResponseObjectWithToolCalls(responseID, model, usagePrompt, result.thinking, result.text, result.parsed.Calls, toolsRaw)
|
||||
|
||||
if !shouldRetryResponsesNonStream(result, attempts) {
|
||||
@@ -78,16 +83,17 @@ func (h *Handler) collectResponsesNonStreamAttempt(w http.ResponseWriter, resp *
|
||||
result := sse.CollectStream(resp, thinkingEnabled, false)
|
||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||
toolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
|
||||
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||
if searchEnabled {
|
||||
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
|
||||
}
|
||||
textParsed := detectAssistantToolCalls(sanitizedText, sanitizedThinking, toolDetectionThinking, toolNames)
|
||||
textParsed := detectAssistantToolCalls(result.Text, sanitizedText, result.Thinking, result.ToolDetectionThinking, toolNames)
|
||||
responseObj := openaifmt.BuildResponseObjectWithToolCalls(responseID, model, usagePrompt, sanitizedThinking, sanitizedText, textParsed.Calls, toolsRaw)
|
||||
return responsesNonStreamResult{
|
||||
rawThinking: result.Thinking,
|
||||
rawText: result.Text,
|
||||
thinking: sanitizedThinking,
|
||||
toolDetectionThinking: toolDetectionThinking,
|
||||
toolDetectionThinking: result.ToolDetectionThinking,
|
||||
text: sanitizedText,
|
||||
contentFilter: result.ContentFilter,
|
||||
parsed: textParsed,
|
||||
|
||||
@@ -130,6 +130,6 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta,
|
||||
return shared.FilterIncrementalToolCallDeltasByAllowed(deltas, seenNames)
|
||||
}
|
||||
|
||||
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
|
||||
func detectAssistantToolCalls(rawText, visibleText, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
return shared.DetectAssistantToolCalls(rawText, visibleText, exposedThinking, detectionThinking, toolNames)
|
||||
}
|
||||
|
||||
@@ -131,12 +131,11 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
|
||||
result := sse.CollectStream(resp, thinkingEnabled, true)
|
||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||
toolDetectionThinking := cleanVisibleOutput(result.ToolDetectionThinking, stripReferenceMarkers)
|
||||
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||
if searchEnabled {
|
||||
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
|
||||
}
|
||||
textParsed := detectAssistantToolCalls(sanitizedText, sanitizedThinking, toolDetectionThinking, toolNames)
|
||||
textParsed := detectAssistantToolCalls(result.Text, sanitizedText, result.Thinking, result.ToolDetectionThinking, toolNames)
|
||||
if len(textParsed.Calls) == 0 && writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ type responsesStreamRuntime struct {
|
||||
toolCallsDoneEmitted bool
|
||||
|
||||
sieve toolstream.State
|
||||
rawThinking strings.Builder
|
||||
thinking strings.Builder
|
||||
toolDetectionThinking strings.Builder
|
||||
rawText strings.Builder
|
||||
text strings.Builder
|
||||
visibleText strings.Builder
|
||||
responseMessageID int
|
||||
@@ -141,15 +143,14 @@ func (s *responsesStreamRuntime) finalize(finishReason string, deferEmptyOutput
|
||||
s.finalErrorStatus = 0
|
||||
s.finalErrorMessage = ""
|
||||
s.finalErrorCode = ""
|
||||
finalThinking := s.thinking.String()
|
||||
finalToolDetectionThinking := s.toolDetectionThinking.String()
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
|
||||
if s.bufferToolContent {
|
||||
s.processToolStreamEvents(toolstream.Flush(&s.sieve, s.toolNames), true, true)
|
||||
}
|
||||
|
||||
textParsed := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
|
||||
finalThinking := s.thinking.String()
|
||||
finalToolDetectionThinking := s.toolDetectionThinking.String()
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
textParsed := detectAssistantToolCalls(s.rawText.String(), finalText, s.rawThinking.String(), finalToolDetectionThinking, s.toolNames)
|
||||
detected := textParsed.Calls
|
||||
s.logToolPolicyRejections(textParsed)
|
||||
|
||||
@@ -227,18 +228,19 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
|
||||
}
|
||||
}
|
||||
for _, p := range parsed.Parts {
|
||||
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
|
||||
if cleanedText == "" {
|
||||
continue
|
||||
}
|
||||
if p.Type != "thinking" && s.searchEnabled && sse.IsCitation(cleanedText) {
|
||||
continue
|
||||
}
|
||||
contentSeen = true
|
||||
if p.Type == "thinking" {
|
||||
rawTrimmed := sse.TrimContinuationOverlap(s.rawThinking.String(), p.Text)
|
||||
if rawTrimmed != "" {
|
||||
s.rawThinking.WriteString(rawTrimmed)
|
||||
contentSeen = true
|
||||
}
|
||||
if !s.thinkingEnabled {
|
||||
continue
|
||||
}
|
||||
cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers)
|
||||
if cleanedText == "" {
|
||||
continue
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
@@ -248,16 +250,28 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
|
||||
continue
|
||||
}
|
||||
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
rawTrimmed := sse.TrimContinuationOverlap(s.rawText.String(), p.Text)
|
||||
if rawTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(trimmed)
|
||||
s.rawText.WriteString(rawTrimmed)
|
||||
contentSeen = true
|
||||
cleanedText := cleanVisibleOutput(rawTrimmed, s.stripReferenceMarkers)
|
||||
if s.searchEnabled && sse.IsCitation(cleanedText) {
|
||||
continue
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed != "" {
|
||||
s.text.WriteString(trimmed)
|
||||
}
|
||||
if !s.bufferToolContent {
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.emitTextDelta(trimmed)
|
||||
continue
|
||||
}
|
||||
s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, trimmed, s.toolNames), true, true)
|
||||
s.processToolStreamEvents(toolstream.ProcessChunk(&s.sieve, rawTrimmed, s.toolNames), true, true)
|
||||
}
|
||||
|
||||
return streamengine.ParsedDecision{ContentSeen: contentSeen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
openaifmt "ds2api/internal/format/openai"
|
||||
"ds2api/internal/sse"
|
||||
"ds2api/internal/toolstream"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,10 @@ func (s *responsesStreamRuntime) sendDone() {
|
||||
func (s *responsesStreamRuntime) processToolStreamEvents(events []toolstream.Event, emitContent bool, resetAfterToolCalls bool) {
|
||||
for _, evt := range events {
|
||||
if emitContent && evt.Content != "" {
|
||||
s.emitTextDelta(evt.Content)
|
||||
cleaned := cleanVisibleOutput(evt.Content, s.stripReferenceMarkers)
|
||||
if cleaned != "" && (!s.searchEnabled || !sse.IsCitation(cleaned)) {
|
||||
s.emitTextDelta(cleaned)
|
||||
}
|
||||
}
|
||||
if len(evt.ToolCallDeltas) > 0 {
|
||||
if !s.emitEarlyToolDeltas {
|
||||
|
||||
@@ -254,8 +254,8 @@ func TestHandleResponsesStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstrea
|
||||
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, []string{"read_file"}, nil, promptcompat.DefaultToolChoicePolicy(), "")
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "event: response.reasoning.delta") {
|
||||
t.Fatalf("expected reasoning delta in stream body, got %s", body)
|
||||
if strings.Contains(body, "event: response.reasoning.delta") {
|
||||
t.Fatalf("did not expect leaked reasoning delta in stream body, got %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: response.function_call_arguments.done") {
|
||||
t.Fatalf("expected finalize fallback function call event, got %s", body)
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"ds2api/internal/toolcall"
|
||||
)
|
||||
|
||||
func DetectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
textParsed := toolcall.ParseStandaloneToolCallsDetailed(text, toolNames)
|
||||
func DetectAssistantToolCalls(rawText, visibleText, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
|
||||
textParsed := toolcall.ParseStandaloneToolCallsDetailed(rawText, toolNames)
|
||||
if len(textParsed.Calls) > 0 {
|
||||
return textParsed
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
if strings.TrimSpace(visibleText) != "" {
|
||||
return textParsed
|
||||
}
|
||||
thinking := detectionThinking
|
||||
|
||||
@@ -13,7 +13,7 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string {
|
||||
if strings.TrimSpace(text) == "" || len(links) == 0 {
|
||||
return text
|
||||
}
|
||||
zeroBased := strings.Contains(strings.ToLower(text), "[reference:0]")
|
||||
zeroBasedReference := hasZeroBasedReferenceMarker(text)
|
||||
return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
sub := citationMarkerPattern.FindStringSubmatch(match)
|
||||
if len(sub) < 3 {
|
||||
@@ -24,7 +24,7 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string {
|
||||
return match
|
||||
}
|
||||
lookupIdx := idx
|
||||
if zeroBased {
|
||||
if strings.EqualFold(sub[1], "reference") && zeroBasedReference {
|
||||
lookupIdx = idx + 1
|
||||
}
|
||||
url := strings.TrimSpace(links[lookupIdx])
|
||||
@@ -34,3 +34,16 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string {
|
||||
return fmt.Sprintf("[%d](%s)", idx, url)
|
||||
})
|
||||
}
|
||||
|
||||
func hasZeroBasedReferenceMarker(text string) bool {
|
||||
for _, sub := range citationMarkerPattern.FindAllStringSubmatch(text, -1) {
|
||||
if len(sub) < 3 || !strings.EqualFold(sub[1], "reference") {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(sub[2]))
|
||||
if err == nil && idx == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package shared
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/toolcall"
|
||||
)
|
||||
|
||||
var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```")
|
||||
@@ -47,10 +49,42 @@ func sanitizeLeakedOutput(text string) string {
|
||||
out = leakedThinkTagPattern.ReplaceAllString(out, "")
|
||||
out = leakedBOSMarkerPattern.ReplaceAllString(out, "")
|
||||
out = leakedMetaMarkerPattern.ReplaceAllString(out, "")
|
||||
out = stripLeakedToolCallWrapperBlocks(out)
|
||||
out = sanitizeLeakedAgentXMLBlocks(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func stripLeakedToolCallWrapperBlocks(text string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
pos := 0
|
||||
for pos < len(text) {
|
||||
tag, ok := toolcall.FindToolMarkupTagOutsideIgnored(text, pos)
|
||||
if !ok {
|
||||
b.WriteString(text[pos:])
|
||||
break
|
||||
}
|
||||
if tag.Start > pos {
|
||||
b.WriteString(text[pos:tag.Start])
|
||||
}
|
||||
if tag.Closing || tag.Name != "tool_calls" {
|
||||
b.WriteString(text[tag.Start : tag.End+1])
|
||||
pos = tag.End + 1
|
||||
continue
|
||||
}
|
||||
closeTag, ok := toolcall.FindMatchingToolMarkupClose(text, tag)
|
||||
if !ok {
|
||||
b.WriteString(text[tag.Start : tag.End+1])
|
||||
pos = tag.End + 1
|
||||
continue
|
||||
}
|
||||
pos = closeTag.End + 1
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripDanglingThinkSuffix(text string) string {
|
||||
matches := leakedThinkTagPattern.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
|
||||
@@ -205,14 +205,14 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
if (detected.length > 0 && !toolCallsDoneEmitted) {
|
||||
toolCallsEmitted = true;
|
||||
toolCallsDoneEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs) });
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(detected, streamToolCallIDs, payload.tools) });
|
||||
} else if (toolSieveEnabled) {
|
||||
const tailEvents = flushToolSieve(toolSieveState, toolNames);
|
||||
for (const evt of tailEvents) {
|
||||
if (evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0) {
|
||||
toolCallsEmitted = true;
|
||||
toolCallsDoneEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) });
|
||||
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
|
||||
continue;
|
||||
}
|
||||
@@ -352,14 +352,14 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
|
||||
if (formatted.length > 0) {
|
||||
toolCallsEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatted });
|
||||
sendDeltaFrame({ tool_calls: formatted });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (evt.type === 'tool_calls') {
|
||||
toolCallsEmitted = true;
|
||||
toolCallsDoneEmitted = true;
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
|
||||
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs, payload.tools) });
|
||||
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
|
||||
continue;
|
||||
}
|
||||
@@ -513,6 +513,11 @@ function observeContinueState(state, chunk) {
|
||||
if (chunk.p === 'response/status') {
|
||||
setContinueStatus(state, asString(chunk.v));
|
||||
}
|
||||
if (chunk.p === 'response') {
|
||||
observeContinueBatchPatches(state, 'response', chunk.v);
|
||||
} else {
|
||||
observeContinueBatchPatches(state, '', chunk.v);
|
||||
}
|
||||
const response = chunk.v && typeof chunk.v === 'object' ? chunk.v.response : null;
|
||||
if (response && typeof response === 'object') {
|
||||
const id = numberValue(response.message_id);
|
||||
@@ -534,13 +539,43 @@ function observeContinueState(state, chunk) {
|
||||
}
|
||||
}
|
||||
|
||||
function observeContinueBatchPatches(state, parentPath, raw) {
|
||||
if (!state || !Array.isArray(raw)) {
|
||||
return;
|
||||
}
|
||||
for (const patch of raw) {
|
||||
if (!patch || typeof patch !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const path = asString(patch.p).trim();
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
let fullPath = path;
|
||||
const parent = asString(parentPath).trim().replace(/^\/+|\/+$/g, '');
|
||||
if (parent && !path.includes('/')) {
|
||||
fullPath = `${parent}/${path}`;
|
||||
}
|
||||
switch (fullPath.replace(/^\/+|\/+$/g, '')) {
|
||||
case 'response/status':
|
||||
case 'status':
|
||||
case 'response/quasi_status':
|
||||
case 'quasi_status':
|
||||
setContinueStatus(state, asString(patch.v));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setContinueStatus(state, status) {
|
||||
const normalized = asString(status).trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
state.lastStatus = normalized;
|
||||
if (normalized.toUpperCase() === 'FINISHED') {
|
||||
if (['FINISHED', 'CONTENT_FILTER'].includes(normalized.toUpperCase())) {
|
||||
state.finished = true;
|
||||
}
|
||||
}
|
||||
@@ -549,7 +584,7 @@ function shouldAutoContinue(state) {
|
||||
if (!state || state.finished || !state.sessionID || state.responseMessageID <= 0) {
|
||||
return false;
|
||||
}
|
||||
return ['WIP', 'INCOMPLETE', 'AUTO_CONTINUE'].includes(asString(state.lastStatus).trim().toUpperCase());
|
||||
return ['INCOMPLETE', 'AUTO_CONTINUE'].includes(asString(state.lastStatus).trim().toUpperCase());
|
||||
}
|
||||
|
||||
function numberValue(v) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function formatOpenAIStreamToolCalls(calls, idStore) {
|
||||
function formatOpenAIStreamToolCalls(calls, idStore, toolsRaw) {
|
||||
if (!Array.isArray(calls) || calls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return calls.map((c, idx) => ({
|
||||
const normalized = normalizeParsedToolCallsForSchemas(calls, toolsRaw);
|
||||
return normalized.map((c, idx) => ({
|
||||
index: idx,
|
||||
id: ensureStreamToolCallID(idStore, idx),
|
||||
type: 'function',
|
||||
@@ -17,6 +18,194 @@ function formatOpenAIStreamToolCalls(calls, idStore) {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeParsedToolCallsForSchemas(calls, toolsRaw) {
|
||||
if (!Array.isArray(calls) || calls.length === 0) {
|
||||
return calls;
|
||||
}
|
||||
const schemas = buildToolSchemaIndex(toolsRaw);
|
||||
if (!schemas) {
|
||||
return calls;
|
||||
}
|
||||
let changedAny = false;
|
||||
const out = calls.map((call) => {
|
||||
const name = String(call && call.name || '').trim().toLowerCase();
|
||||
const schema = schemas[name];
|
||||
if (!schema || !call || !call.input || typeof call.input !== 'object' || Array.isArray(call.input)) {
|
||||
return call;
|
||||
}
|
||||
const [normalized, changed] = normalizeToolValueWithSchema(call.input, schema);
|
||||
if (!changed || !normalized || typeof normalized !== 'object' || Array.isArray(normalized)) {
|
||||
return call;
|
||||
}
|
||||
changedAny = true;
|
||||
return { ...call, input: normalized };
|
||||
});
|
||||
return changedAny ? out : calls;
|
||||
}
|
||||
|
||||
function buildToolSchemaIndex(toolsRaw) {
|
||||
if (!Array.isArray(toolsRaw) || toolsRaw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const out = {};
|
||||
for (const item of toolsRaw) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
continue;
|
||||
}
|
||||
const [name, schema] = extractToolNameAndSchema(item);
|
||||
if (!name || !schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
continue;
|
||||
}
|
||||
out[name.toLowerCase()] = schema;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
function extractToolNameAndSchema(tool) {
|
||||
const fn = tool && typeof tool.function === 'object' && !Array.isArray(tool.function) ? tool.function : null;
|
||||
const name = firstNonEmptyString(tool.name, fn && fn.name);
|
||||
const schema = firstNonNil(
|
||||
tool.parameters,
|
||||
tool.input_schema,
|
||||
tool.inputSchema,
|
||||
tool.schema,
|
||||
fn && fn.parameters,
|
||||
fn && fn.input_schema,
|
||||
fn && fn.inputSchema,
|
||||
fn && fn.schema,
|
||||
);
|
||||
return [name, schema];
|
||||
}
|
||||
|
||||
function normalizeToolValueWithSchema(value, schema) {
|
||||
if (value == null || !schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return [value, false];
|
||||
}
|
||||
if (shouldCoerceSchemaToString(schema)) {
|
||||
return stringifySchemaValue(value);
|
||||
}
|
||||
if (looksLikeObjectSchema(schema)) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return [value, false];
|
||||
}
|
||||
const properties = schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties) ? schema.properties : null;
|
||||
const additional = schema.additionalProperties;
|
||||
let changed = false;
|
||||
const out = {};
|
||||
for (const [key, current] of Object.entries(value)) {
|
||||
let next = current;
|
||||
let fieldChanged = false;
|
||||
if (properties && Object.prototype.hasOwnProperty.call(properties, key)) {
|
||||
[next, fieldChanged] = normalizeToolValueWithSchema(current, properties[key]);
|
||||
} else if (additional != null) {
|
||||
[next, fieldChanged] = normalizeToolValueWithSchema(current, additional);
|
||||
}
|
||||
out[key] = next;
|
||||
changed = changed || fieldChanged;
|
||||
}
|
||||
return changed ? [out, true] : [value, false];
|
||||
}
|
||||
if (looksLikeArraySchema(schema)) {
|
||||
if (!Array.isArray(value) || value.length === 0 || schema.items == null) {
|
||||
return [value, false];
|
||||
}
|
||||
let changed = false;
|
||||
const out = value.map((item, idx) => {
|
||||
const itemSchema = Array.isArray(schema.items) ? schema.items[idx] : schema.items;
|
||||
if (itemSchema == null) {
|
||||
return item;
|
||||
}
|
||||
const [next, itemChanged] = normalizeToolValueWithSchema(item, itemSchema);
|
||||
changed = changed || itemChanged;
|
||||
return next;
|
||||
});
|
||||
return changed ? [out, true] : [value, false];
|
||||
}
|
||||
return [value, false];
|
||||
}
|
||||
|
||||
function shouldCoerceSchemaToString(schema) {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof schema.const === 'string') {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0 && schema.enum.every((item) => typeof item === 'string')) {
|
||||
return true;
|
||||
}
|
||||
if (typeof schema.type === 'string') {
|
||||
return schema.type.trim().toLowerCase() === 'string';
|
||||
}
|
||||
if (Array.isArray(schema.type) && schema.type.length > 0) {
|
||||
let hasString = false;
|
||||
for (const item of schema.type) {
|
||||
if (typeof item !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const typ = item.trim().toLowerCase();
|
||||
if (typ === 'string') {
|
||||
hasString = true;
|
||||
} else if (typ !== 'null') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return hasString;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeObjectSchema(schema) {
|
||||
return !!schema && typeof schema === 'object' && !Array.isArray(schema) && (
|
||||
(typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'object') ||
|
||||
(schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) ||
|
||||
schema.additionalProperties != null
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeArraySchema(schema) {
|
||||
return !!schema && typeof schema === 'object' && !Array.isArray(schema) && (
|
||||
(typeof schema.type === 'string' && schema.type.trim().toLowerCase() === 'array') ||
|
||||
schema.items != null
|
||||
);
|
||||
}
|
||||
|
||||
function stringifySchemaValue(value) {
|
||||
if (value == null) {
|
||||
return [value, false];
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return [value, false];
|
||||
}
|
||||
try {
|
||||
return [JSON.stringify(value), true];
|
||||
} catch {
|
||||
return [value, false];
|
||||
}
|
||||
}
|
||||
|
||||
function firstNonNil(...values) {
|
||||
for (const value of values) {
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonEmptyString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function ensureStreamToolCallID(idStore, index) {
|
||||
if (!(idStore instanceof Map)) {
|
||||
return `call_${newCallID()}`;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package promptcompat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/prompt"
|
||||
)
|
||||
|
||||
const historySplitInjectedFilename = "IGNORE"
|
||||
const CurrentInputContextFilename = "history.txt"
|
||||
|
||||
func BuildOpenAIHistoryTranscript(messages []any) string {
|
||||
return buildOpenAIInjectedFileTranscript(messages)
|
||||
@@ -32,5 +31,5 @@ func buildOpenAIInjectedFileTranscript(messages []any) string {
|
||||
if transcript == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("[file content end]\n\n%s\n\n[file name]: %s\n[file content begin]\n", transcript, historySplitInjectedFilename)
|
||||
return transcript
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestStandardRequestCompletionPayloadSetsModelTypeFromResolvedModel(t *testi
|
||||
{name: "default", model: "deepseek-v4-flash", thinking: false, search: false, modelType: "default"},
|
||||
{name: "default_nothinking", model: "deepseek-v4-flash-nothinking", thinking: false, search: false, modelType: "default"},
|
||||
{name: "expert", model: "deepseek-v4-pro", thinking: true, search: false, modelType: "expert"},
|
||||
{name: "vision", model: "deepseek-v4-vision-search", thinking: false, search: true, modelType: "vision"},
|
||||
{name: "vision", model: "deepseek-v4-vision", thinking: true, search: false, modelType: "vision"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
|
||||
@@ -30,13 +30,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy ToolChoiceP
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := tool["function"].(map[string]any)
|
||||
if len(fn) == 0 {
|
||||
fn = tool
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
desc, _ := fn["description"].(string)
|
||||
schema, _ := fn["parameters"].(map[string]any)
|
||||
name, desc, schema := toolcall.ExtractToolMeta(tool)
|
||||
name = strings.TrimSpace(name)
|
||||
if !isAllowed(name) {
|
||||
continue
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
parsedLineBufferSize = 128
|
||||
scannerBufferSize = 64 * 1024
|
||||
maxScannerLineSize = 2 * 1024 * 1024
|
||||
minFlushChars = 160
|
||||
maxFlushWait = 80 * time.Millisecond
|
||||
)
|
||||
|
||||
// StartParsedLinePump scans an upstream DeepSeek SSE body and emits normalized
|
||||
@@ -20,21 +23,123 @@ func StartParsedLinePump(ctx context.Context, body io.Reader, thinkingEnabled bo
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 0, scannerBufferSize), maxScannerLineSize)
|
||||
type scanItem struct {
|
||||
line []byte
|
||||
err error
|
||||
eof bool
|
||||
}
|
||||
lineCh := make(chan scanItem, 1)
|
||||
stopScanner := make(chan struct{})
|
||||
defer close(stopScanner)
|
||||
go func() {
|
||||
sendScanItem := func(item scanItem) bool {
|
||||
select {
|
||||
case lineCh <- item:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-stopScanner:
|
||||
return false
|
||||
}
|
||||
}
|
||||
defer close(lineCh)
|
||||
scanner := bufio.NewScanner(body)
|
||||
scanner.Buffer(make([]byte, 0, scannerBufferSize), maxScannerLineSize)
|
||||
for scanner.Scan() {
|
||||
line := append([]byte{}, scanner.Bytes()...)
|
||||
if !sendScanItem(scanItem{line: line}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = sendScanItem(scanItem{err: scanner.Err(), eof: true})
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(maxFlushWait)
|
||||
defer ticker.Stop()
|
||||
currentType := initialType
|
||||
for scanner.Scan() {
|
||||
line := append([]byte{}, scanner.Bytes()...)
|
||||
result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType)
|
||||
currentType = result.NextType
|
||||
var pending *LineResult
|
||||
pendingChars := 0
|
||||
|
||||
sendResult := func(r LineResult) bool {
|
||||
select {
|
||||
case out <- r:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
flushPending := func() bool {
|
||||
if pending == nil {
|
||||
return true
|
||||
}
|
||||
if !sendResult(*pending) {
|
||||
return false
|
||||
}
|
||||
pending = nil
|
||||
pendingChars = 0
|
||||
return true
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case out <- result:
|
||||
case <-ctx.Done():
|
||||
done <- ctx.Err()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !flushPending() {
|
||||
return
|
||||
}
|
||||
case item, ok := <-lineCh:
|
||||
if !ok || item.eof {
|
||||
if !flushPending() {
|
||||
return
|
||||
}
|
||||
done <- item.err
|
||||
return
|
||||
}
|
||||
line := item.line
|
||||
result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType)
|
||||
currentType = result.NextType
|
||||
|
||||
canAccumulate := result.Parsed && !result.Stop && result.ErrorMessage == "" && !result.ContentFilter && result.ResponseMessageID == 0
|
||||
if canAccumulate {
|
||||
lineChars := 0
|
||||
for _, p := range result.Parts {
|
||||
lineChars += len(p.Text)
|
||||
}
|
||||
for _, p := range result.ToolDetectionThinkingParts {
|
||||
lineChars += len(p.Text)
|
||||
}
|
||||
if lineChars > 0 {
|
||||
if pending == nil {
|
||||
cp := result
|
||||
pending = &cp
|
||||
} else {
|
||||
pending.Parts = append(pending.Parts, result.Parts...)
|
||||
pending.ToolDetectionThinkingParts = append(pending.ToolDetectionThinkingParts, result.ToolDetectionThinkingParts...)
|
||||
pending.NextType = result.NextType
|
||||
}
|
||||
pendingChars += lineChars
|
||||
if pendingChars < minFlushChars {
|
||||
continue
|
||||
}
|
||||
if !flushPending() {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !flushPending() {
|
||||
return
|
||||
}
|
||||
if !sendResult(result) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
done <- scanner.Err()
|
||||
}()
|
||||
return out, done
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ func TestStartParsedLinePumpMultipleLines(t *testing.T) {
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(collected) < 3 {
|
||||
t.Fatalf("expected at least 3 results, got %d", len(collected))
|
||||
if len(collected) < 2 {
|
||||
t.Fatalf("expected at least 2 results, got %d", len(collected))
|
||||
}
|
||||
// First should be thinking
|
||||
if collected[0].Parts[0].Type != "thinking" {
|
||||
@@ -175,3 +175,31 @@ func TestStartParsedLinePumpThinkingDisabled(t *testing.T) {
|
||||
t.Fatalf("expected at least 1 part, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpAccumulatesSmallChunks(t *testing.T) {
|
||||
body := strings.NewReader(
|
||||
"data: {\"p\":\"response/content\",\"v\":\"h\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"i\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
|
||||
results, done := StartParsedLinePump(context.Background(), body, false, "text")
|
||||
|
||||
collected := make([]LineResult, 0)
|
||||
for r := range results {
|
||||
collected = append(collected, r)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(collected) != 2 {
|
||||
t.Fatalf("expected 2 results (accumulated content + done), got %d", len(collected))
|
||||
}
|
||||
if len(collected[0].Parts) != 2 {
|
||||
t.Fatalf("expected 2 accumulated parts, got %d", len(collected[0].Parts))
|
||||
}
|
||||
if !collected[1].Stop {
|
||||
t.Fatal("expected second result to stop")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +92,45 @@ func filterToolCallsDetailed(parsed []ParsedToolCall) ([]ParsedToolCall, []strin
|
||||
if tc.Input == nil {
|
||||
tc.Input = map[string]any{}
|
||||
}
|
||||
if len(tc.Input) > 0 && !toolCallInputHasMeaningfulValue(tc.Input) {
|
||||
continue
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toolCallInputHasMeaningfulValue(v any) bool {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
return strings.TrimSpace(x) != ""
|
||||
case map[string]any:
|
||||
if len(x) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, child := range x {
|
||||
if toolCallInputHasMeaningfulValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case []any:
|
||||
if len(x) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, child := range x {
|
||||
if toolCallInputHasMeaningfulValue(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeToolCallSyntax(text string) bool {
|
||||
hasDSML, hasCanonical := ContainsToolCallWrapperSyntaxOutsideIgnored(text)
|
||||
return hasDSML || hasCanonical
|
||||
|
||||
@@ -48,7 +48,7 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, schema := extractToolNameAndSchema(tool)
|
||||
name, _, schema := ExtractToolMeta(tool)
|
||||
if name == "" || schema == nil {
|
||||
continue
|
||||
}
|
||||
@@ -60,24 +60,31 @@ func buildToolSchemaIndex(toolsRaw any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func extractToolNameAndSchema(tool map[string]any) (string, any) {
|
||||
func ExtractToolMeta(tool map[string]any) (string, string, any) {
|
||||
name := strings.TrimSpace(asStringValue(tool["name"]))
|
||||
schema := tool["parameters"]
|
||||
if schema == nil {
|
||||
schema = tool["input_schema"]
|
||||
}
|
||||
desc := strings.TrimSpace(asStringValue(tool["description"]))
|
||||
schema := firstNonNil(
|
||||
tool["parameters"],
|
||||
tool["input_schema"],
|
||||
tool["inputSchema"],
|
||||
tool["schema"],
|
||||
)
|
||||
if fn, ok := tool["function"].(map[string]any); ok {
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(asStringValue(fn["name"]))
|
||||
}
|
||||
if schema == nil {
|
||||
schema = fn["parameters"]
|
||||
}
|
||||
if schema == nil {
|
||||
schema = fn["input_schema"]
|
||||
if desc == "" {
|
||||
desc = strings.TrimSpace(asStringValue(fn["description"]))
|
||||
}
|
||||
schema = firstNonNil(
|
||||
schema,
|
||||
fn["parameters"],
|
||||
fn["input_schema"],
|
||||
fn["inputSchema"],
|
||||
fn["schema"],
|
||||
)
|
||||
}
|
||||
return name, schema
|
||||
return name, desc, schema
|
||||
}
|
||||
|
||||
func normalizeToolValueWithSchema(value any, schema any) (any, bool) {
|
||||
@@ -264,3 +271,12 @@ func asStringValue(v any) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonNil(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,3 +110,52 @@ func TestNormalizeParsedToolCallsForSchemasLeavesAmbiguousUnionUnchanged(t *test
|
||||
t.Fatalf("expected ambiguous union to stay unchanged, got %#v", got[0].Input["taskId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeParsedToolCallsForSchemasSupportsCamelCaseInputSchema(t *testing.T) {
|
||||
toolsRaw := []any{
|
||||
map[string]any{
|
||||
"name": "Write",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
calls := []ParsedToolCall{{Name: "Write", Input: map[string]any{"content": map[string]any{"message": "hi"}}}}
|
||||
got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw)
|
||||
if got[0].Input["content"] != `{"message":"hi"}` {
|
||||
t.Fatalf("expected camelCase inputSchema content coercion, got %#v", got[0].Input["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeParsedToolCallsForSchemasPreservesArrayWhenSchemaSaysArray(t *testing.T) {
|
||||
toolsRaw := []any{
|
||||
map[string]any{
|
||||
"name": "todowrite",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"todos": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
"status": map[string]any{"type": "string"},
|
||||
"priority": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
todos := []any{map[string]any{"content": "x", "status": "pending", "priority": "high"}}
|
||||
calls := []ParsedToolCall{{Name: "todowrite", Input: map[string]any{"todos": todos}}}
|
||||
got := NormalizeParsedToolCallsForSchemas(calls, toolsRaw)
|
||||
if !reflect.DeepEqual(got[0].Input["todos"], todos) {
|
||||
t.Fatalf("expected todos array preserved, got %#v want %#v", got[0].Input["todos"], todos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,28 @@ func TestParseToolCallsDetailedMarksToolCallsSyntax(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsRejectsAllEmptyParameterPayload(t *testing.T) {
|
||||
text := `<tool_calls><invoke name="Bash"><parameter name="command"></parameter><parameter name="description"> </parameter><parameter name="timeout"></parameter></invoke></tool_calls>`
|
||||
res := ParseToolCallsDetailed(text, []string{"Bash"})
|
||||
if !res.SawToolCallSyntax {
|
||||
t.Fatalf("expected tool syntax to be detected, got %#v", res)
|
||||
}
|
||||
if len(res.Calls) != 0 {
|
||||
t.Fatalf("expected all-empty payload to be rejected, got %#v", res.Calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsPreservesExplicitZeroArgToolCall(t *testing.T) {
|
||||
text := `<tool_calls><invoke name="noop"></invoke></tool_calls>`
|
||||
res := ParseToolCallsDetailed(text, []string{"noop"})
|
||||
if len(res.Calls) != 1 {
|
||||
t.Fatalf("expected zero-arg tool call to remain valid, got %#v", res.Calls)
|
||||
}
|
||||
if len(res.Calls[0].Input) != 0 {
|
||||
t.Fatalf("expected empty input map for zero-arg tool call, got %#v", res.Calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsSupportsInlineJSONToolObject(t *testing.T) {
|
||||
text := `<tool_calls><invoke name="Bash">{"input":{"command":"pwd","description":"show cwd"}}</invoke></tool_calls>`
|
||||
calls := ParseToolCalls(text, []string{"bash"})
|
||||
|
||||
@@ -44,14 +44,21 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
|
||||
xmlBlock := captured[tag.Start : closeTag.End+1]
|
||||
prefixPart := captured[:tag.Start]
|
||||
suffixPart := captured[closeTag.End+1:]
|
||||
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
|
||||
if len(parsed) > 0 {
|
||||
parsed := toolcall.ParseStandaloneToolCallsDetailed(xmlBlock, toolNames)
|
||||
if len(parsed.Calls) > 0 {
|
||||
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
|
||||
if best == nil || tag.Start < best.start {
|
||||
best = &candidate{start: tag.Start, prefix: prefixPart, calls: parsed, suffix: suffixPart}
|
||||
best = &candidate{start: tag.Start, prefix: prefixPart, calls: parsed.Calls, suffix: suffixPart}
|
||||
}
|
||||
break
|
||||
}
|
||||
if parsed.SawToolCallSyntax {
|
||||
if rejected == nil || tag.Start < rejected.start {
|
||||
rejected = &rejectedBlock{start: tag.Start, prefix: prefixPart, suffix: suffixPart}
|
||||
}
|
||||
searchFrom = tag.End + 1
|
||||
continue
|
||||
}
|
||||
if rejected == nil || tag.Start < rejected.start {
|
||||
rejected = &rejectedBlock{start: tag.Start, prefix: prefixPart + xmlBlock, suffix: suffixPart}
|
||||
}
|
||||
@@ -75,10 +82,13 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
|
||||
xmlBlock := "<tool_calls>" + captured[invokeTag.Start:closeTag.End+1]
|
||||
prefixPart := captured[:invokeTag.Start]
|
||||
suffixPart := captured[closeTag.End+1:]
|
||||
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
|
||||
if len(parsed) > 0 {
|
||||
parsed := toolcall.ParseStandaloneToolCallsDetailed(xmlBlock, toolNames)
|
||||
if len(parsed.Calls) > 0 {
|
||||
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
|
||||
return prefixPart, parsed, suffixPart, true
|
||||
return prefixPart, parsed.Calls, suffixPart, true
|
||||
}
|
||||
if parsed.SawToolCallSyntax {
|
||||
return prefixPart, nil, suffixPart, true
|
||||
}
|
||||
return prefixPart + captured[invokeTag.Start:closeTag.End+1], nil, suffixPart, true
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) {
|
||||
func TestProcessToolSieveSuppressesMalformedExecutableXMLBlock(t *testing.T) {
|
||||
var state State
|
||||
chunk := `<tool_calls><invoke name="read_file"><param>{"path":"README.md"}</param></invoke></tool_calls>`
|
||||
events := ProcessChunk(&state, chunk, []string{"read_file"})
|
||||
@@ -302,10 +302,39 @@ func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T)
|
||||
}
|
||||
|
||||
if toolCalls != 0 {
|
||||
t.Fatalf("expected malformed executable-looking XML to stay text, got %d events=%#v", toolCalls, events)
|
||||
t.Fatalf("expected malformed executable-looking XML not to become a tool call, got %d events=%#v", toolCalls, events)
|
||||
}
|
||||
if textContent.String() != chunk {
|
||||
t.Fatalf("expected malformed executable-looking XML to pass through unchanged, got %q", textContent.String())
|
||||
if textContent.Len() != 0 {
|
||||
t.Fatalf("expected malformed executable-looking XML to be suppressed, got %q", textContent.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessToolSieveSuppressesAllEmptyDSMLToolBlock(t *testing.T) {
|
||||
var state State
|
||||
chunk := strings.Join([]string{
|
||||
`<|DSML|tool_calls>`,
|
||||
`<|DSML|invoke name="Bash">`,
|
||||
`<|DSML|parameter name="command"></|DSML|parameter>`,
|
||||
`<|DSML|parameter name="description"> </|DSML|parameter>`,
|
||||
`<|DSML|parameter name="timeout"></|DSML|parameter>`,
|
||||
`</|DSML|invoke>`,
|
||||
`</|DSML|tool_calls>`,
|
||||
}, "\n")
|
||||
events := ProcessChunk(&state, chunk, []string{"Bash"})
|
||||
events = append(events, Flush(&state, []string{"Bash"})...)
|
||||
|
||||
var textContent strings.Builder
|
||||
toolCalls := 0
|
||||
for _, evt := range events {
|
||||
textContent.WriteString(evt.Content)
|
||||
toolCalls += len(evt.ToolCalls)
|
||||
}
|
||||
|
||||
if toolCalls != 0 {
|
||||
t.Fatalf("expected all-empty DSML block not to produce tool calls, got %d events=%#v", toolCalls, events)
|
||||
}
|
||||
if textContent.Len() != 0 {
|
||||
t.Fatalf("expected all-empty DSML block not to leak as text, got %q", textContent.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
internal/util/text.go
Normal file
46
internal/util/text.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package util
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// TruncateRunes trims a string to at most limit Unicode code points.
|
||||
func TruncateRunes(text string, limit int) (string, bool) {
|
||||
if limit < 0 {
|
||||
return text, false
|
||||
}
|
||||
if limit == 0 {
|
||||
return "", text != ""
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := range text {
|
||||
if count == limit {
|
||||
return text[:i], true
|
||||
}
|
||||
count++
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
// TruncateUTF8Bytes trims a string to fit within limit bytes without cutting
|
||||
// through a UTF-8 code point boundary.
|
||||
func TruncateUTF8Bytes(text string, limit int) (string, bool) {
|
||||
if limit < 0 {
|
||||
return text, false
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text, false
|
||||
}
|
||||
if limit == 0 {
|
||||
return "", true
|
||||
}
|
||||
|
||||
raw := []byte(text)
|
||||
cut := limit
|
||||
if cut > len(raw) {
|
||||
cut = len(raw)
|
||||
}
|
||||
for cut > 0 && cut < len(raw) && !utf8.RuneStart(raw[cut]) {
|
||||
cut--
|
||||
}
|
||||
return string(raw[:cut]), true
|
||||
}
|
||||
56
start.mjs
56
start.mjs
@@ -126,9 +126,12 @@ function binaryExists() {
|
||||
|
||||
// 查找占用端口的进程 PID
|
||||
function findPidByPort(port) {
|
||||
const numericPort = parseInt(port, 10);
|
||||
if (isNaN(numericPort)) return [];
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
const output = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
|
||||
const output = execSync(`netstat -ano | findstr :${numericPort} | findstr LISTENING`, {
|
||||
encoding: 'utf-8',
|
||||
shell: true,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
@@ -141,7 +144,7 @@ function findPidByPort(port) {
|
||||
}
|
||||
return [...pids];
|
||||
} else {
|
||||
const output = execSync(`lsof -ti :${port}`, {
|
||||
const output = execSync(`lsof -ti :${numericPort}`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
@@ -217,7 +220,7 @@ async function installFrontendDeps() {
|
||||
const proc = spawn('npm', ['ci', '--registry', MIRRORS.npm], {
|
||||
cwd: CONFIG.webuiDir,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
shell: isWindows,
|
||||
});
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端依赖安装失败')));
|
||||
});
|
||||
@@ -239,7 +242,7 @@ async function buildBackend() {
|
||||
const proc = spawn('go', ['build', '-o', BINARY, './cmd/ds2api'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
shell: isWindows,
|
||||
env: { ...process.env, GOPROXY: MIRRORS.goproxy },
|
||||
});
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('后端编译失败')));
|
||||
@@ -257,22 +260,21 @@ async function buildWebui() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
'npm', ['run', 'build', '--', '--outDir', CONFIG.staticAdminDir, '--emptyOutDir'],
|
||||
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: true }
|
||||
{ cwd: CONFIG.webuiDir, stdio: 'inherit', shell: isWindows }
|
||||
);
|
||||
proc.on('close', code => code === 0 ? resolve() : reject(new Error('前端构建失败')));
|
||||
});
|
||||
}
|
||||
|
||||
// 启动后端(开发模式:go run,无需预编译)
|
||||
async function startBackendDev() {
|
||||
if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)');
|
||||
log.info(`启动后端(go run)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
|
||||
const proc = spawn('go', ['run', './cmd/ds2api'], {
|
||||
async function startBackendDev() {
|
||||
if (!checkGo()) throw new Error('未找到 Go,请先安装 Go (https://go.dev/dl/)');
|
||||
log.info(`启动后端(go run)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
|
||||
const proc = spawn('go', ['run', './cmd/ds2api'], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: {
|
||||
...process.env,
|
||||
shell: isWindows,
|
||||
env: { ...process.env,
|
||||
PORT: CONFIG.port,
|
||||
LOG_LEVEL: CONFIG.logLevel,
|
||||
DS2API_ADMIN_KEY: CONFIG.adminKey,
|
||||
@@ -284,13 +286,13 @@ async function startBackendDev() {
|
||||
}
|
||||
|
||||
// 启动后端(生产模式:运行编译好的二进制)
|
||||
async function startBackendProd() {
|
||||
if (!binaryExists()) {
|
||||
log.warn('未找到编译产物,正在编译...');
|
||||
await buildBackend();
|
||||
}
|
||||
log.info(`启动后端(二进制)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
|
||||
const proc = spawn(BINARY, [], {
|
||||
async function startBackendProd() {
|
||||
if (!binaryExists()) {
|
||||
log.warn('未找到编译产物,正在编译...');
|
||||
await buildBackend();
|
||||
}
|
||||
log.info(`启动后端(二进制)... 本地 http://127.0.0.1:${CONFIG.port} 绑定 0.0.0.0:${CONFIG.port}`);
|
||||
const proc = spawn(BINARY, [], {
|
||||
cwd: __dirname,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
@@ -323,14 +325,14 @@ async function startFrontend() {
|
||||
}
|
||||
|
||||
// 显示状态信息
|
||||
function showStatus() {
|
||||
console.log('\n' + '─'.repeat(50));
|
||||
log.success(`后端 API: http://127.0.0.1:${CONFIG.port}`);
|
||||
log.success(`管理界面: http://127.0.0.1:${CONFIG.port}/admin`);
|
||||
log.info(`后端绑定: 0.0.0.0:${CONFIG.port} (可通过局域网 IP 访问)`);
|
||||
if (existsSync(CONFIG.webuiDir)) {
|
||||
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
|
||||
}
|
||||
function showStatus() {
|
||||
console.log('\n' + '─'.repeat(50));
|
||||
log.success(`后端 API: http://127.0.0.1:${CONFIG.port}`);
|
||||
log.success(`管理界面: http://127.0.0.1:${CONFIG.port}/admin`);
|
||||
log.info(`后端绑定: 0.0.0.0:${CONFIG.port} (可通过局域网 IP 访问)`);
|
||||
if (existsSync(CONFIG.webuiDir)) {
|
||||
log.success(`前端 Dev: http://localhost:${CONFIG.frontendPort}`);
|
||||
}
|
||||
console.log('─'.repeat(50));
|
||||
log.info('按 Ctrl+C 停止所有服务\n');
|
||||
}
|
||||
|
||||
@@ -188,6 +188,30 @@ test('parseToolCalls treats single-item CDATA body as array', () => {
|
||||
assert.deepEqual(calls[0].input.todos, ['one']);
|
||||
});
|
||||
|
||||
test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => {
|
||||
const formatted = formatOpenAIStreamToolCalls([
|
||||
{ name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } },
|
||||
], new Map(), [
|
||||
{ name: 'Write', inputSchema: { type: 'object', properties: { content: { type: 'string' }, taskId: { type: 'string' } } } },
|
||||
]);
|
||||
assert.equal(formatted.length, 1);
|
||||
const args = JSON.parse(formatted[0].function.arguments);
|
||||
assert.equal(args.content, '{"message":"hi"}');
|
||||
assert.equal(args.taskId, '1');
|
||||
});
|
||||
|
||||
test('formatOpenAIStreamToolCalls preserves arrays when schema says array', () => {
|
||||
const todos = [{ content: 'x', status: 'pending', priority: 'high' }];
|
||||
const formatted = formatOpenAIStreamToolCalls([
|
||||
{ name: 'todowrite', input: { todos } },
|
||||
], new Map(), [
|
||||
{ name: 'todowrite', inputSchema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object' } } } } },
|
||||
]);
|
||||
assert.equal(formatted.length, 1);
|
||||
const args = JSON.parse(formatted[0].function.arguments);
|
||||
assert.deepEqual(args.todos, todos);
|
||||
});
|
||||
|
||||
test('parseToolCalls treats CDATA object fragment as object', () => {
|
||||
const fragment = '<question><![CDATA[Pick one]]></question><options><item><label><![CDATA[A]]></label></item><item><label><![CDATA[B]]></label></item></options>';
|
||||
const payload = `<tool_calls><invoke name="AskUserQuestion"><parameter name="questions"><![CDATA[${fragment}]]></parameter></invoke></tool_calls>`;
|
||||
|
||||
@@ -11,9 +11,7 @@ function describeModel(t, modelID) {
|
||||
const noThinking = modelID.endsWith('-nothinking')
|
||||
|
||||
let description = t('apiTester.models.generic')
|
||||
if (modelID.includes('vision-search')) {
|
||||
description = t('apiTester.models.visionSearch')
|
||||
} else if (modelID.includes('vision')) {
|
||||
if (modelID.includes('vision')) {
|
||||
description = t('apiTester.models.vision')
|
||||
} else if (modelID.includes('pro-search')) {
|
||||
description = t('apiTester.models.proSearch')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, Bot, ChevronDown, Clock3, Copy, Download, Loader2, MessageSquareText, RefreshCcw, Sparkles, Trash2, UserRound, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -9,9 +9,14 @@ const DISABLED_LIMIT = 0
|
||||
const MESSAGE_COLLAPSE_AT = 700
|
||||
const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode'
|
||||
const BEGIN_SENTENCE_MARKER = '<|begin▁of▁sentence|>'
|
||||
const SYSTEM_MARKER = '<|System|>'
|
||||
const USER_MARKER = '<|User|>'
|
||||
const ASSISTANT_MARKER = '<|Assistant|>'
|
||||
const TOOL_MARKER = '<|Tool|>'
|
||||
const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>'
|
||||
const END_SENTENCE_MARKER = '<|end▁of▁sentence|>'
|
||||
const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>'
|
||||
const CURRENT_INPUT_FILE_PROMPT = 'The current request and prior conversation context have already been provided. Answer the latest user request directly.'
|
||||
|
||||
function formatDateTime(value, lang) {
|
||||
if (!value) return '-'
|
||||
@@ -109,6 +114,54 @@ function MergeModeIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function downloadTextFile(filename, text) {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function fallbackCopyText(text) {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.setAttribute('readonly', '')
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.top = '-9999px'
|
||||
textArea.style.left = '-9999px'
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
throw new Error('copy failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextWithFallback(text) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to execCommand fallback.
|
||||
}
|
||||
fallbackCopyText(text)
|
||||
}
|
||||
|
||||
function skipWhitespace(text, start) {
|
||||
let cursor = start
|
||||
while (cursor < text.length && /\s/.test(text[cursor])) {
|
||||
@@ -131,7 +184,9 @@ function parseStrictHistoryMessages(historyText) {
|
||||
|
||||
while (cursor < transcript.length) {
|
||||
if (expectedRole === null) {
|
||||
if (transcript.startsWith(USER_MARKER, cursor)) {
|
||||
if (transcript.startsWith(SYSTEM_MARKER, cursor)) {
|
||||
expectedRole = 'system'
|
||||
} else if (transcript.startsWith(USER_MARKER, cursor)) {
|
||||
expectedRole = 'user'
|
||||
} else if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
|
||||
expectedRole = 'assistant'
|
||||
@@ -142,13 +197,32 @@ function parseStrictHistoryMessages(historyText) {
|
||||
}
|
||||
}
|
||||
|
||||
if (transcript.startsWith(SYSTEM_MARKER, cursor)) {
|
||||
if (expectedRole !== 'system') return null
|
||||
cursor += SYSTEM_MARKER.length
|
||||
const nextInstructionsEnd = transcript.indexOf(END_INSTRUCTIONS_MARKER, cursor)
|
||||
if (nextInstructionsEnd < 0) return null
|
||||
parsed.push({
|
||||
role: 'system',
|
||||
content: transcript.slice(cursor, nextInstructionsEnd),
|
||||
})
|
||||
cursor = nextInstructionsEnd + END_INSTRUCTIONS_MARKER.length
|
||||
expectedRole = 'user'
|
||||
continue
|
||||
}
|
||||
|
||||
if (transcript.startsWith(USER_MARKER, cursor)) {
|
||||
if (expectedRole !== 'user') return null
|
||||
if (expectedRole !== 'user' && expectedRole !== 'user_or_tool' && expectedRole !== 'assistant_or_user') return null
|
||||
cursor += USER_MARKER.length
|
||||
const nextAssistant = transcript.indexOf(ASSISTANT_MARKER, cursor)
|
||||
const nextTool = transcript.indexOf(TOOL_MARKER, cursor)
|
||||
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
|
||||
if (nextAssistant < 0) return null
|
||||
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextAssistant) {
|
||||
let nextRoleIndex = nextAssistant
|
||||
if (nextRoleIndex < 0 || (nextTool >= 0 && nextTool < nextRoleIndex)) {
|
||||
nextRoleIndex = nextTool
|
||||
}
|
||||
if (nextRoleIndex < 0) return null
|
||||
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextRoleIndex) {
|
||||
const assistantStart = skipWhitespace(transcript, nextSentenceEnd + END_SENTENCE_MARKER.length)
|
||||
if (!transcript.startsWith(ASSISTANT_MARKER, assistantStart)) return null
|
||||
parsed.push({
|
||||
@@ -161,21 +235,26 @@ function parseStrictHistoryMessages(historyText) {
|
||||
}
|
||||
parsed.push({
|
||||
role: 'user',
|
||||
content: transcript.slice(cursor, nextAssistant),
|
||||
content: transcript.slice(cursor, nextRoleIndex),
|
||||
})
|
||||
const assistantStart = nextAssistant + ASSISTANT_MARKER.length
|
||||
if (transcript.startsWith(TOOL_MARKER, nextRoleIndex)) {
|
||||
cursor = nextRoleIndex
|
||||
expectedRole = 'tool'
|
||||
continue
|
||||
}
|
||||
const assistantStart = nextRoleIndex + ASSISTANT_MARKER.length
|
||||
if (transcript.indexOf(END_SENTENCE_MARKER, assistantStart) < 0) {
|
||||
trailingAssistantPromptOnly = true
|
||||
cursor = assistantStart
|
||||
break
|
||||
}
|
||||
cursor = nextAssistant
|
||||
cursor = nextRoleIndex
|
||||
expectedRole = 'assistant'
|
||||
continue
|
||||
}
|
||||
|
||||
if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
|
||||
if (expectedRole !== 'assistant') return null
|
||||
if (expectedRole !== 'assistant' && expectedRole !== 'assistant_or_user') return null
|
||||
cursor += ASSISTANT_MARKER.length
|
||||
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
|
||||
if (nextSentenceEnd < 0) return null
|
||||
@@ -184,11 +263,28 @@ function parseStrictHistoryMessages(historyText) {
|
||||
content: transcript.slice(cursor, nextSentenceEnd),
|
||||
})
|
||||
cursor = nextSentenceEnd + END_SENTENCE_MARKER.length
|
||||
expectedRole = 'user'
|
||||
expectedRole = 'user_or_tool'
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.length && expectedRole === 'user') break
|
||||
if (transcript.startsWith(TOOL_MARKER, cursor)) {
|
||||
if (expectedRole !== 'tool' && expectedRole !== 'user' && expectedRole !== 'user_or_tool') return null
|
||||
cursor += TOOL_MARKER.length
|
||||
const nextToolResultsEnd = transcript.indexOf(END_TOOL_RESULTS_MARKER, cursor)
|
||||
if (nextToolResultsEnd < 0) return null
|
||||
parsed.push({
|
||||
role: 'tool',
|
||||
content: transcript.slice(cursor, nextToolResultsEnd),
|
||||
})
|
||||
cursor = nextToolResultsEnd + END_TOOL_RESULTS_MARKER.length
|
||||
expectedRole = 'assistant_or_user'
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.length
|
||||
&& (expectedRole === 'user' || expectedRole === 'user_or_tool' || expectedRole === 'assistant_or_user')
|
||||
) break
|
||||
if (transcript.slice(cursor).trim() === '') break
|
||||
return null
|
||||
}
|
||||
@@ -214,6 +310,14 @@ function buildListModeMessages(item, t) {
|
||||
return { messages: liveMessages, historyMerged: false }
|
||||
}
|
||||
|
||||
const placeholderOnly = liveMessages.length === 1
|
||||
&& String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user'
|
||||
&& String(liveMessages[0]?.content || '').trim() === CURRENT_INPUT_FILE_PROMPT
|
||||
|
||||
if (placeholderOnly) {
|
||||
return { messages: historyMessages, historyMerged: true }
|
||||
}
|
||||
|
||||
const insertAt = liveMessages.findIndex(message => {
|
||||
const role = String(message?.role || '').trim().toLowerCase()
|
||||
return role !== 'system' && role !== 'developer'
|
||||
@@ -275,8 +379,28 @@ function RequestMessages({ item, t, messages }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MergedPromptView({ item, t }) {
|
||||
function MergedPromptView({ item, t, onMessage }) {
|
||||
const merged = item?.final_prompt || ''
|
||||
const mergedFilename = `Merged_${item?.id || 'prompt'}.txt`
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextWithFallback(merged)
|
||||
onMessage?.('success', t('chatHistory.copySuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadTextFile(mergedFilename, merged)
|
||||
onMessage?.('success', t('chatHistory.downloadSuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-4xl mx-auto rounded-2xl border px-5 py-4"
|
||||
@@ -285,8 +409,28 @@ function MergedPromptView({ item, t }) {
|
||||
borderColor: 'rgba(231, 176, 8, 0.45)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300] mb-3">
|
||||
{t('chatHistory.mergedInput')}
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-[#5b4300]">
|
||||
{t('chatHistory.mergedInput')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
|
||||
title={t('chatHistory.copyMerged')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="h-8 w-8 rounded-lg text-[#5b4300] hover:text-black hover:bg-[#fff8db]/45 flex items-center justify-center transition-colors"
|
||||
title={t('chatHistory.downloadMerged')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-[#2f2200] whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
@@ -300,14 +444,53 @@ function MergedPromptView({ item, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryTextView({ item, t }) {
|
||||
function HistoryTextView({ item, t, onMessage }) {
|
||||
const historyText = (item?.history_text || '').trim()
|
||||
if (!historyText) return null
|
||||
const historyFilename = `History_${item?.id || 'history'}.txt`
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextWithFallback(historyText)
|
||||
onMessage?.('success', t('chatHistory.copySuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
downloadTextFile(historyFilename, historyText)
|
||||
onMessage?.('success', t('chatHistory.downloadSuccess'))
|
||||
} catch {
|
||||
onMessage?.('error', t('chatHistory.downloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto rounded-2xl border border-border bg-background px-5 py-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-3 text-left">
|
||||
HISTORY
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground text-left">
|
||||
HISTORY
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
|
||||
title={t('chatHistory.copyHistory')}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="h-8 w-8 rounded-lg border border-border bg-background text-muted-foreground hover:text-foreground hover:bg-secondary/70 flex items-center justify-center"
|
||||
title={t('chatHistory.downloadHistory')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-foreground whitespace-pre-wrap break-words font-mono">
|
||||
<ExpandableText
|
||||
@@ -322,18 +505,18 @@ function HistoryTextView({ item, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
|
||||
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName, onMessage }) {
|
||||
if (!selectedItem) return null
|
||||
const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
|
||||
const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} />}
|
||||
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
{viewMode === 'list'
|
||||
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
|
||||
: <MergedPromptView item={selectedItem} t={t} />}
|
||||
: <MergedPromptView item={selectedItem} t={t} onMessage={onMessage} />}
|
||||
|
||||
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
|
||||
<div className={clsx(
|
||||
@@ -908,6 +1091,7 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
|
||||
detailScrollRef={detailScrollRef}
|
||||
assistantStartRef={assistantStartRef}
|
||||
bottomButtonClassName="absolute right-5 bottom-5"
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,6 @@
|
||||
"flashSearch": "v4 Flash (with search)",
|
||||
"proSearch": "v4 Pro (with search)",
|
||||
"vision": "v4 Vision (thinking on by default)",
|
||||
"visionSearch": "v4 Vision (with search)",
|
||||
"generic": "Compatible model",
|
||||
"noThinking": "thinking forced off"
|
||||
},
|
||||
@@ -284,6 +283,14 @@
|
||||
"selectPrompt": "Select a record on the left to view details.",
|
||||
"mergedInput": "Final message sent to DeepSeek",
|
||||
"emptyMergedPrompt": "No merged prompt is available.",
|
||||
"copyHistory": "Copy HISTORY",
|
||||
"downloadHistory": "Download HISTORY",
|
||||
"copyMerged": "Copy merged prompt",
|
||||
"downloadMerged": "Download merged prompt",
|
||||
"copySuccess": "Copied successfully.",
|
||||
"copyFailed": "Copy failed.",
|
||||
"downloadSuccess": "Downloaded successfully.",
|
||||
"downloadFailed": "Download failed.",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"reasoningTrace": "Reasoning Trace",
|
||||
@@ -387,7 +394,7 @@
|
||||
"thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
|
||||
"currentInputFileTitle": "Independent Split",
|
||||
"currentInputFileEnabled": "Independent split (by size)",
|
||||
"currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a hidden context file.",
|
||||
"currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a history.txt context file.",
|
||||
"currentInputFileMinChars": "Current input threshold (characters)",
|
||||
"currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
|
||||
"compatibilityTitle": "Compatibility",
|
||||
|
||||
@@ -224,7 +224,6 @@
|
||||
"flashSearch": "v4 Flash(带搜索)",
|
||||
"proSearch": "v4 Pro(带搜索)",
|
||||
"vision": "v4 Vision(默认开启思考)",
|
||||
"visionSearch": "v4 Vision(带搜索)",
|
||||
"generic": "兼容模型",
|
||||
"noThinking": "强制关闭思考"
|
||||
},
|
||||
@@ -284,6 +283,14 @@
|
||||
"selectPrompt": "从左侧选择一条记录查看详情。",
|
||||
"mergedInput": "最终发送给 DeepSeek 的完整消息",
|
||||
"emptyMergedPrompt": "没有可展示的完整消息。",
|
||||
"copyHistory": "复制 HISTORY",
|
||||
"downloadHistory": "下载 HISTORY",
|
||||
"copyMerged": "复制完整消息",
|
||||
"downloadMerged": "下载完整消息",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFailed": "复制失败",
|
||||
"downloadSuccess": "下载成功",
|
||||
"downloadFailed": "下载失败",
|
||||
"expand": "展开全部",
|
||||
"collapse": "收起",
|
||||
"reasoningTrace": "思维链过程",
|
||||
@@ -387,7 +394,7 @@
|
||||
"thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
|
||||
"currentInputFileTitle": "独立拆分",
|
||||
"currentInputFileEnabled": "独立拆分(按量)",
|
||||
"currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为隐藏上下文文件。",
|
||||
"currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为 history.txt 上下文文件。",
|
||||
"currentInputFileMinChars": "当前输入阈值(字符数)",
|
||||
"currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
|
||||
"compatibilityTitle": "兼容性设置",
|
||||
|
||||
Reference in New Issue
Block a user