diff --git a/API.en.md b/API.en.md index ee1b73b..8112452 100644 --- a/API.en.md +++ b/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`. diff --git a/API.md b/API.md index 8fb77ba..f47f8f9 100644 --- a/API.md +++ b/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` diff --git a/Dockerfile b/Dockerfile index ac062f7..d5113f6 100644 --- a/Dockerfile +++ b/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 diff --git a/README.MD b/README.MD index 3c7b4d9..0440a3c 100644 --- a/README.MD +++ b/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` 留空时使用内置默认提示词。 diff --git a/README.en.md b/README.en.md index 32390e5..609acd8 100644 --- a/README.en.md +++ b/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). diff --git a/VERSION b/VERSION index 4d0dcda..6aba2b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.1.2 +4.2.0 diff --git a/cmd/ds2api/main.go b/cmd/ds2api/main.go index a081a48..a4ba77e 100644 --- a/cmd/ds2api/main.go +++ b/cmd/ds2api/main.go @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index 9398fdc..571829a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index f81de01..4c1df13 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -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). diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 0f91fdf..47dfd4b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` 登录(建议首次登录后自行更换为强密码)。 diff --git a/docs/DeepSeekSSE行为结构说明-2026-04-05.md b/docs/DeepSeekSSE行为结构说明-2026-04-05.md index 9dc7fe0..a5c1fce 100644 --- a/docs/DeepSeekSSE行为结构说明-2026-04-05.md +++ b/docs/DeepSeekSSE行为结构说明-2026-04-05.md @@ -309,7 +309,7 @@ parse SSE block - 新模型可能增加新的 `p` 路径。 - 新版本可能增加新的 fragment.type。 - `CONTENT_FILTER` 的终态模板内容可能变化。 -- 自动续写相关状态(如 `INCOMPLETE` / `AUTO_CONTINUE`)当前主要来自实测与实现兼容逻辑,后续字段形态仍可能变化。 +- 自动续写相关状态(如 `INCOMPLETE` / `AUTO_CONTINUE`)当前主要来自实测与实现兼容逻辑,后续字段形态仍可能变化。当前实现不会仅因早期 `WIP` 状态就自动继续;只有显式 `INCOMPLETE` 或 `auto_continue` 信号才会触发 continue。 - 解析器应当对未知字段、未知路径、未知事件保持容忍。 如果你要把这份说明用于实际开发,建议同时保留原始流样本、回放脚本和回归测试,不要只依赖本文。 diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 16cf38c..5bf6025 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -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 之前,会默认 兼容层仍接受旧式纯 `` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。 数组参数使用 `...` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `urgent` 这种行内标记,兼容层会保留原始字符串,不会强行升成 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 对这套统一语义的复用关系变更 diff --git a/internal/chathistory/store.go b/internal/chathistory/store.go index 8f215a1..857b8bc 100644 --- a/internal/chathistory/store.go +++ b/internal/chathistory/store.go @@ -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 } diff --git a/internal/chathistory/store_test.go b/internal/chathistory/store_test.go index e923755..d6209ef 100644 --- a/internal/chathistory/store_test.go +++ b/internal/chathistory/store_test.go @@ -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) diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 55b928d..41f3dd9 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -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 { diff --git a/internal/config/model_alias_test.go b/internal/config/model_alias_test.go index 64cbda8..459edcc 100644 --- a/internal/config/model_alias_test.go +++ b/internal/config/model_alias_test.go @@ -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) } } diff --git a/internal/config/models.go b/internal/config/models.go index 1349ef1..555fad2 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -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: diff --git a/internal/config/paths.go b/internal/config/paths.go index e3cc249..7b8f22d 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -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") } diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go new file mode 100644 index 0000000..00fa51a --- /dev/null +++ b/internal/config/paths_test.go @@ -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") + } + }) +} diff --git a/internal/config/store.go b/internal/config/store.go index a54e012..0af367f 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -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 diff --git a/internal/deepseek/client/client_continue.go b/internal/deepseek/client/client_continue.go index aea30cc..b76d921 100644 --- a/internal/deepseek/client/client_continue.go +++ b/internal/deepseek/client/client_continue.go @@ -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 == "" { + return "" + } + return s + } +} diff --git a/internal/deepseek/client/client_continue_test.go b/internal/deepseek/client/client_continue_test.go index 83a42af..234d2af 100644 --- a/internal/deepseek/client/client_continue_test.go +++ b/internal/deepseek/client/client_continue_test.go @@ -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 } diff --git a/internal/devcapture/store.go b/internal/devcapture/store.go index c5b3cec..64561c5 100644 --- a/internal/devcapture/store.go +++ b/internal/devcapture/store.go @@ -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 } diff --git a/internal/devcapture/store_test.go b/internal/devcapture/store_test.go index 3bbbf2d..91854c5 100644 --- a/internal/devcapture/store_test.go +++ b/internal/devcapture/store_test.go @@ -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") + } +} diff --git a/internal/httpapi/admin/accounts/handler_accounts_testing.go b/internal/httpapi/admin/accounts/handler_accounts_testing.go index 3b41c60..d92c1dc 100644 --- a/internal/httpapi/admin/accounts/handler_accounts_testing.go +++ b/internal/httpapi/admin/accounts/handler_accounts_testing.go @@ -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 } diff --git a/internal/httpapi/admin/rawsamples/handler_raw_samples.go b/internal/httpapi/admin/rawsamples/handler_raw_samples.go index a30e214..df86077 100644 --- a/internal/httpapi/admin/rawsamples/handler_raw_samples.go +++ b/internal/httpapi/admin/rawsamples/handler_raw_samples.go @@ -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 { diff --git a/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go b/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go index 780c0ef..c4756d1 100644 --- a/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go +++ b/internal/httpapi/admin/rawsamples/handler_raw_samples_test.go @@ -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() diff --git a/internal/httpapi/claude/handler_helpers_misc.go b/internal/httpapi/claude/handler_helpers_misc.go index 7b89734..6062dc6 100644 --- a/internal/httpapi/claude/handler_helpers_misc.go +++ b/internal/httpapi/claude/handler_helpers_misc.go @@ -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 } diff --git a/internal/httpapi/claude/handler_messages.go b/internal/httpapi/claude/handler_messages.go index ad8f54e..de47d28 100644 --- a/internal/httpapi/claude/handler_messages.go +++ b/internal/httpapi/claude/handler_messages.go @@ -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() diff --git a/internal/httpapi/claude/handler_stream_test.go b/internal/httpapi/claude/handler_stream_test.go index 354ed89..16b5dde 100644 --- a/internal/httpapi/claude/handler_stream_test.go +++ b/internal/httpapi/claude/handler_stream_test.go @@ -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":"{\"input\":{\"content\":{\"message\":\"hi\"},\"taskId\":1}}"}`, + `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()) +} diff --git a/internal/httpapi/claude/standard_request.go b/internal/httpapi/claude/standard_request.go index 3f3e238..3f10723 100644 --- a/internal/httpapi/claude/standard_request.go +++ b/internal/httpapi/claude/standard_request.go @@ -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"]), diff --git a/internal/httpapi/claude/standard_request_test.go b/internal/httpapi/claude/standard_request_test.go index 6110124..244b2ac 100644 --- a/internal/httpapi/claude/standard_request_test.go +++ b/internal/httpapi/claude/standard_request_test.go @@ -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() diff --git a/internal/httpapi/claude/stream_runtime_core.go b/internal/httpapi/claude/stream_runtime_core.go index beb2d40..49fde53 100644 --- a/internal/httpapi/claude/stream_runtime_core.go +++ b/internal/httpapi/claude/stream_runtime_core.go @@ -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, diff --git a/internal/httpapi/claude/stream_runtime_finalize.go b/internal/httpapi/claude/stream_runtime_finalize.go index 241ff7a..32e9b5f 100644 --- a/internal/httpapi/claude/stream_runtime_finalize.go +++ b/internal/httpapi/claude/stream_runtime_finalize.go @@ -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 diff --git a/internal/httpapi/openai/chat/chat_history_test.go b/internal/httpapi/openai/chat/chat_history_test.go index ec28d8a..1fd1b93 100644 --- a/internal/httpapi/openai/chat/chat_history_test.go +++ b/internal/httpapi/openai/chat/chat_history_test.go @@ -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) diff --git a/internal/httpapi/openai/chat/chat_stream_runtime.go b/internal/httpapi/openai/chat/chat_stream_runtime.go index ec3909b..21d1f4f 100644 --- a/internal/httpapi/openai/chat/chat_stream_runtime.go +++ b/internal/httpapi/openai/chat/chat_stream_runtime.go @@ -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{ diff --git a/internal/httpapi/openai/chat/empty_retry_runtime.go b/internal/httpapi/openai/chat/empty_retry_runtime.go index 2cb4273..c3d37b9 100644 --- a/internal/httpapi/openai/chat/empty_retry_runtime.go +++ b/internal/httpapi/openai/chat/empty_retry_runtime.go @@ -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), diff --git a/internal/httpapi/openai/chat/handler.go b/internal/httpapi/openai/chat/handler.go index 92da0c6..4ad7aad 100644 --- a/internal/httpapi/openai/chat/handler.go +++ b/internal/httpapi/openai/chat/handler.go @@ -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) } diff --git a/internal/httpapi/openai/chat/handler_chat.go b/internal/httpapi/openai/chat/handler_chat.go index 57616cd..a2e421a 100644 --- a/internal/httpapi/openai/chat/handler_chat.go +++ b/internal/httpapi/openai/chat/handler_chat.go @@ -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 { diff --git a/internal/httpapi/openai/chat/handler_toolcall_test.go b/internal/httpapi/openai/chat/handler_toolcall_test.go index 3184c15..0574b17 100644 --- a/internal/httpapi/openai/chat/handler_toolcall_test.go +++ b/internal/httpapi/openai/chat/handler_toolcall_test.go @@ -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()) } diff --git a/internal/httpapi/openai/citation_links_test.go b/internal/httpapi/openai/citation_links_test.go index a7f10d0..3c891ab 100644 --- a/internal/httpapi/openai/citation_links_test.go +++ b/internal/httpapi/openai/citation_links_test.go @@ -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) + } +} diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index 981a5ee..8a24575 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -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) diff --git a/internal/httpapi/openai/history_split_test.go b/internal/httpapi/openai/history_split_test.go index aa76575..593735a 100644 --- a/internal/httpapi/openai/history_split_test.go +++ b/internal/httpapi/openai/history_split_test.go @@ -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) diff --git a/internal/httpapi/openai/leaked_output_sanitize_test.go b/internal/httpapi/openai/leaked_output_sanitize_test.go index e72bf02..acaf720 100644 --- a/internal/httpapi/openai/leaked_output_sanitize_test.go +++ b/internal/httpapi/openai/leaked_output_sanitize_test.go @@ -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\">\n\n\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.Some final answer" got := sanitizeLeakedOutput(raw) diff --git a/internal/httpapi/openai/responses/empty_retry_runtime.go b/internal/httpapi/openai/responses/empty_retry_runtime.go index 006a2ae..a451c92 100644 --- a/internal/httpapi/openai/responses/empty_retry_runtime.go +++ b/internal/httpapi/openai/responses/empty_retry_runtime.go @@ -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, diff --git a/internal/httpapi/openai/responses/handler.go b/internal/httpapi/openai/responses/handler.go index a5f243f..ac8cd04 100644 --- a/internal/httpapi/openai/responses/handler.go +++ b/internal/httpapi/openai/responses/handler.go @@ -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) } diff --git a/internal/httpapi/openai/responses/responses_handler.go b/internal/httpapi/openai/responses/responses_handler.go index 54e25ee..a04e7b1 100644 --- a/internal/httpapi/openai/responses/responses_handler.go +++ b/internal/httpapi/openai/responses/responses_handler.go @@ -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 } diff --git a/internal/httpapi/openai/responses/responses_stream_runtime_core.go b/internal/httpapi/openai/responses/responses_stream_runtime_core.go index 077b723..2047dfe 100644 --- a/internal/httpapi/openai/responses/responses_stream_runtime_core.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_core.go @@ -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} diff --git a/internal/httpapi/openai/responses/responses_stream_runtime_events.go b/internal/httpapi/openai/responses/responses_stream_runtime_events.go index d497f04..20b9108 100644 --- a/internal/httpapi/openai/responses/responses_stream_runtime_events.go +++ b/internal/httpapi/openai/responses/responses_stream_runtime_events.go @@ -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 { diff --git a/internal/httpapi/openai/responses/responses_stream_test.go b/internal/httpapi/openai/responses/responses_stream_test.go index ef0b202..80ae5c7 100644 --- a/internal/httpapi/openai/responses/responses_stream_test.go +++ b/internal/httpapi/openai/responses/responses_stream_test.go @@ -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) diff --git a/internal/httpapi/openai/shared/assistant_toolcalls.go b/internal/httpapi/openai/shared/assistant_toolcalls.go index 25f930b..f90860f 100644 --- a/internal/httpapi/openai/shared/assistant_toolcalls.go +++ b/internal/httpapi/openai/shared/assistant_toolcalls.go @@ -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 diff --git a/internal/httpapi/openai/shared/citation_links.go b/internal/httpapi/openai/shared/citation_links.go index 9b2b77f..b4e2f33 100644 --- a/internal/httpapi/openai/shared/citation_links.go +++ b/internal/httpapi/openai/shared/citation_links.go @@ -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 +} diff --git a/internal/httpapi/openai/shared/leaked_output_sanitize.go b/internal/httpapi/openai/shared/leaked_output_sanitize.go index 0b0b897..5e54637 100644 --- a/internal/httpapi/openai/shared/leaked_output_sanitize.go +++ b/internal/httpapi/openai/shared/leaked_output_sanitize.go @@ -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 { diff --git a/internal/js/chat-stream/vercel_stream_impl.js b/internal/js/chat-stream/vercel_stream_impl.js index dfd6aad..3f34fb6 100644 --- a/internal/js/chat-stream/vercel_stream_impl.js +++ b/internal/js/chat-stream/vercel_stream_impl.js @@ -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) { diff --git a/internal/js/helpers/stream-tool-sieve/format.js b/internal/js/helpers/stream-tool-sieve/format.js index 74da078..88d7271 100644 --- a/internal/js/helpers/stream-tool-sieve/format.js +++ b/internal/js/helpers/stream-tool-sieve/format.js @@ -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()}`; diff --git a/internal/promptcompat/history_transcript.go b/internal/promptcompat/history_transcript.go index 93bf4ba..a3f7905 100644 --- a/internal/promptcompat/history_transcript.go +++ b/internal/promptcompat/history_transcript.go @@ -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 } diff --git a/internal/promptcompat/standard_request_test.go b/internal/promptcompat/standard_request_test.go index 437888d..72aa74e 100644 --- a/internal/promptcompat/standard_request_test.go +++ b/internal/promptcompat/standard_request_test.go @@ -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 { diff --git a/internal/promptcompat/tool_prompt.go b/internal/promptcompat/tool_prompt.go index ba5f2cf..95d2f8b 100644 --- a/internal/promptcompat/tool_prompt.go +++ b/internal/promptcompat/tool_prompt.go @@ -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 diff --git a/internal/sse/stream.go b/internal/sse/stream.go index 4aa2d39..8b8aa9b 100644 --- a/internal/sse/stream.go +++ b/internal/sse/stream.go @@ -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 } diff --git a/internal/sse/stream_edge_test.go b/internal/sse/stream_edge_test.go index 5de3a58..7c5ea4c 100644 --- a/internal/sse/stream_edge_test.go +++ b/internal/sse/stream_edge_test.go @@ -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") + } +} diff --git a/internal/toolcall/toolcalls_parse.go b/internal/toolcall/toolcalls_parse.go index f5f9d39..5c2a04e 100644 --- a/internal/toolcall/toolcalls_parse.go +++ b/internal/toolcall/toolcalls_parse.go @@ -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 diff --git a/internal/toolcall/toolcalls_schema_normalize.go b/internal/toolcall/toolcalls_schema_normalize.go index 44c772a..65a27c2 100644 --- a/internal/toolcall/toolcalls_schema_normalize.go +++ b/internal/toolcall/toolcalls_schema_normalize.go @@ -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 +} diff --git a/internal/toolcall/toolcalls_schema_normalize_test.go b/internal/toolcall/toolcalls_schema_normalize_test.go index 7807c3f..7dac106 100644 --- a/internal/toolcall/toolcalls_schema_normalize_test.go +++ b/internal/toolcall/toolcalls_schema_normalize_test.go @@ -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) + } +} diff --git a/internal/toolcall/toolcalls_test.go b/internal/toolcall/toolcalls_test.go index 5bfe865..b68955b 100644 --- a/internal/toolcall/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -323,6 +323,28 @@ func TestParseToolCallsDetailedMarksToolCallsSyntax(t *testing.T) { } } +func TestParseToolCallsRejectsAllEmptyParameterPayload(t *testing.T) { + text := ` ` + 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 := `` + 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 := `{"input":{"command":"pwd","description":"show cwd"}}` calls := ParseToolCalls(text, []string{"bash"}) diff --git a/internal/toolstream/tool_sieve_xml.go b/internal/toolstream/tool_sieve_xml.go index a95bc7e..f2a5718 100644 --- a/internal/toolstream/tool_sieve_xml.go +++ b/internal/toolstream/tool_sieve_xml.go @@ -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 := "" + 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 } diff --git a/internal/toolstream/tool_sieve_xml_test.go b/internal/toolstream/tool_sieve_xml_test.go index c05e6cb..f31aeea 100644 --- a/internal/toolstream/tool_sieve_xml_test.go +++ b/internal/toolstream/tool_sieve_xml_test.go @@ -288,7 +288,7 @@ func TestProcessToolSieveNonToolXMLKeepsSuffixForToolParsing(t *testing.T) { } } -func TestProcessToolSievePassesThroughMalformedExecutableXMLBlock(t *testing.T) { +func TestProcessToolSieveSuppressesMalformedExecutableXMLBlock(t *testing.T) { var state State chunk := `{"path":"README.md"}` 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 name="description"> `, + `<|DSML|parameter name="timeout">`, + ``, + ``, + }, "\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()) } } diff --git a/internal/util/text.go b/internal/util/text.go new file mode 100644 index 0000000..1beae5b --- /dev/null +++ b/internal/util/text.go @@ -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 +} diff --git a/start.mjs b/start.mjs index 7f037be..c335085 100644 --- a/start.mjs +++ b/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'); } diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index d26b8ca..f8265f7 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -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 = ''; const payload = ``; diff --git a/webui/src/features/apiTester/ApiTesterContainer.jsx b/webui/src/features/apiTester/ApiTesterContainer.jsx index fe79a35..dabd049 100644 --- a/webui/src/features/apiTester/ApiTesterContainer.jsx +++ b/webui/src/features/apiTester/ApiTesterContainer.jsx @@ -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') diff --git a/webui/src/features/chatHistory/ChatHistoryContainer.jsx b/webui/src/features/chatHistory/ChatHistoryContainer.jsx index fe28a2a..e0b574f 100644 --- a/webui/src/features/chatHistory/ChatHistoryContainer.jsx +++ b/webui/src/features/chatHistory/ChatHistoryContainer.jsx @@ -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 (
-
- {t('chatHistory.mergedInput')} +
+
+ {t('chatHistory.mergedInput')} +
+
+ + +
{ + 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 (
-
- HISTORY +
+
+ HISTORY +
+
+ + +
- {showHistoryAtTop && } + {showHistoryAtTop && } {viewMode === 'list' ? - : } + : }
)}
diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index a554725..0b3de63 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -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", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index 239b3fc..9aa127b 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -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": "兼容性设置",