Compare commits

..

24 Commits

Author SHA1 Message Date
CJACK.
d3c0e747a4 Merge pull request #336 from CJackHwang/dev
dev → main: 空输出重试、Vercel 自动续写、current_input_file 重构与 DSML 兼容增强
2026-04-28 00:10:35 +08:00
CJACK
d40888496e refactor: consolidate current_input_file prompt into BuildOpenAICurrentInputContextPrompt
Extract the compacted-context prompt string into a single function in
promptcompat and add a [context note] block to the injected file wrapper
so the model knows the attached history is compressed context, not new
instructions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:01:14 +08:00
CJACK
28bb85ad63 refactor: replace history_split with current_input_file configuration
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:36:56 +08:00
CJACK
1e9170e385 补丁 2026-04-27 22:57:48 +08:00
CJACK.
9e4c5eff7b Merge pull request #331 from CJackHwang/dev
4.1.0
2026-04-27 21:37:22 +08:00
CJACK
b82bc1311a fix: use parent_message_id and fresh PoW headers for empty-output retry and continue
Previously retry/continue requests reused the initial PoW header and
lacked parent_message_id, causing them to land as disconnected root
messages in the DeepSeek session instead of proper follow-up turns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:31:51 +08:00
CJACK
fb43bd92f5 版本号修改 2026-04-27 20:12:33 +08:00
CJACK
0378d8c0a9 feat: add empty-output retry and Vercel auto-continue support
- Auto-retry Chat/Responses streams once when upstream output is empty but not content-filtered, reusing session/token/PoW and appending a regeneration suffix to the prompt
- Wire DeepSeek continue API into Vercel streams for multi-round thinking output exhaustion
- Defer empty-output errors in stream finalizers to enable synthetic retry; only surface failure when the retry budget is exhausted
- Track content_filter stops to avoid retry on filtered outputs
- Add comprehensive tests for stream/non-stream retry, Responses retry, and content_filter no-retry
- Update prompt-compatibility.md documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:00:52 +08:00
CJACK
2d5d211a7a refactor: unify Go/Node XML tool markup scanning and expand DSML alias support
- Add shared ToolMarkupTag scanner (toolcalls_scan.go) replacing hardcoded alias tables
- Support DSML collapsed tag names (<DSMLtool_calls>, <DSMLinvoke>, <DSMLparameter>)
- Parse JSON literal values from parameter bodies (123→number, true→bool, null)
- Recover unclosed CDATA in final parse/flush via SanitizeLooseCDATA
- Align Go and Node implementations (scanToolMarkupTagAt, findMatchingToolMarkupClose)
- Reject bare <invoke> as unsupported syntax, only tool_calls wrapper triggers tool path
- Update API.md and toolcall-semantics.md documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:53:59 +08:00
CJACK
70467054c3 fix: preserve partial-update fields for current_input_file and thinking_injection, expand DSML space-separator aliases
- Guard current_input_file.enabled / thinking_injection.{enabled,prompt} with hasNestedSettingsKey so partial updates don't overwrite omitted fields
- Expand DSML alias support to tolerate space-separated tags (e.g. <|dsml invoke>) alongside pipe-separated forms
- Sync Go sieve, Node sieve, toolcall parser, and tests for all new DSML variants
- Update API.md and toolcall-semantics.md with expanded alias coverage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:06:44 +08:00
CJACK
6959aa2982 feat: add ETag cache optimization, code-split WebUI, and refactor XML tool scanner
- Chat history: early 304 via Revision()/DetailRevision() to avoid full snapshot reads
- WebUI: lazy-load tab containers with Suspense fallback
- Toolstream: split tool_sieve_xml.go into tags.go and scan.go
- CI: trigger on main branch, guard cross-build to dev/main pushes only
- Docs: add DEVELOPER.md developer quick reference

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 14:37:23 +08:00
CJACK
1602c3a43c lint 2026-04-27 13:48:55 +08:00
CJACK
a13293e113 feat: expand DSML tool-call alias and fence handling
Add support for DSML wrapper aliases (<dsml|tool_calls>, <|tool_calls>,
<|tool_calls>) alongside canonical XML. Normalize mixed DSML/canonical
tags instead of rejecting them. Add tilde fence (~~~) support, fix
nested fence and unclosed fence handling, support CDATA-protected fence
content, and skip prose mentions when scanning for real tool blocks.
Mirror all changes between Go and Node.js runtimes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 13:39:50 +08:00
CJACK
90ce595325 chore: update project files 2026-04-27 02:09:11 +08:00
CJACK
40d5e3ebb5 测试DSML 2026-04-27 00:21:26 +08:00
CJACK.
645fce41c8 Merge pull request #323 from ouqiting/fix_chat_histroy
feat: merge parsed HISTORY into chat history list
2026-04-26 22:46:19 +08:00
ouqiting
9360397197 fix(webui): remove hardcoded HISTORY wrapper parsing 2026-04-26 18:32:02 +08:00
ouqiting
162920f5d5 feat(webui): merge parsed HISTORY into chat history list 2026-04-26 18:21:51 +08:00
CJACK.
4048c3784b Merge pull request #320 from adnxx1wsx/main
fix: fallback claude non-stream tool calls from thinking
2026-04-26 17:54:05 +08:00
MiY
a505f2cb96 fix: fallback tool calls from thinking on empty output 2026-04-26 17:45:12 +08:00
MiY
e2dfe15f48 fix: fallback claude non-stream tool calls from thinking 2026-04-26 17:33:21 +08:00
CJACK
22e951b4c4 增加不同上下文模式 2026-04-26 14:21:15 +08:00
CJACK
c09a4b51a5 feat: 新增 thinking 注入配置支持,扩展设置管理与前端交互
新增 promptcompat 和 OpenAI shared 层的 thinking 注入逻辑,
完善配置系统的编解码与校验,更新设置管理 API 与前端 UI。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 13:35:20 +08:00
CJACK
3627c7366d 修复裸 invoke 标签导致流式输出卡住的问题
当模型输出不带 <tool_calls> 包裹的裸 <invoke> 标签时(如文档示例或行内引用),
之前的逻辑会一直等待关闭标签导致流式输出卡住。现在通过 shouldKeepBareInvokeCapture
判断是否为可修复的调用,不可修复的直接作为文本释放。

同时更新 README 文档中工具调用解析说明,并移除 allow-list 过滤的过时描述。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 11:14:26 +08:00
136 changed files with 7936 additions and 1252 deletions

View File

@@ -5,12 +5,23 @@ on:
push:
branches:
- dev
- main
permissions:
contents: read
concurrency:
group: quality-gates-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.26.x"
NODE_VERSION: "24"
GOLANGCI_LINT_VERSION: "v2.11.4"
jobs:
quality-gates:
lint-and-refactor:
name: Lint and Refactor Gate
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -19,19 +30,13 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: webui/package-lock.json
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: go.sum
- name: Setup golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.11.4
version: ${{ env.GOLANGCI_LINT_VERSION }}
install-mode: binary
verify: true
@@ -41,10 +46,88 @@ jobs:
- name: Refactor Line Gate
run: ./tests/scripts/check-refactor-line-gate.sh
go-unit:
name: Go Unit (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- macos-latest
- windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: go.sum
- name: Go Unit Gate
run: ./tests/scripts/run-unit-go.sh
unit-all:
name: Unit Gates (Go + Node)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: go.sum
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: webui/package-lock.json
- name: Unit Gates (Go + Node)
run: ./tests/scripts/run-unit-all.sh
webui-build:
name: WebUI Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: webui/package-lock.json
- name: WebUI Build Gate
run: |
npm ci --prefix webui
npm ci --prefix webui --prefer-offline --no-audit
npm run build --prefix webui
cross-build:
name: Release Target Cross-Build
if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: go.sum
- name: Cross-Build Release Targets
env:
CROSS_BUILD_JOBS: "3"
run: ./tests/scripts/check-cross-build.sh

View File

@@ -15,6 +15,14 @@ permissions:
contents: write
packages: write
concurrency:
group: release-artifacts-${{ github.event.release.tag_name || github.event.inputs.release_tag }}
cancel-in-progress: false
env:
GO_VERSION: "1.26.x"
NODE_VERSION: "24"
jobs:
build-and-upload:
runs-on: ubuntu-latest
@@ -27,12 +35,13 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.26.x"
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: go.sum
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
cache-dependency-path: webui/package-lock.json
@@ -44,52 +53,13 @@ jobs:
- name: Build WebUI
run: |
npm ci --prefix webui
npm ci --prefix webui --prefer-offline --no-audit
npm run build --prefix webui
- name: Build Multi-Platform Archives
run: |
set -euo pipefail
TAG="${RELEASE_TAG}"
BUILD_VERSION="${TAG}"
if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then
BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')"
fi
mkdir -p dist
targets=(
"linux/amd64"
"linux/arm64"
"darwin/amd64"
"darwin/arm64"
"windows/amd64"
)
for target in "${targets[@]}"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
PKG="ds2api_${TAG}_${GOOS}_${GOARCH}"
STAGE="dist/${PKG}"
BIN="ds2api"
if [ "${GOOS}" = "windows" ]; then
BIN="ds2api.exe"
fi
mkdir -p "${STAGE}/static"
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api
cp config.example.json .env.example LICENSE README.MD README.en.md "${STAGE}/"
cp -R static/admin "${STAGE}/static/admin"
if [ "${GOOS}" = "windows" ]; then
(cd dist && zip -rq "${PKG}.zip" "${PKG}")
else
tar -C dist -czf "dist/${PKG}.tar.gz" "${PKG}"
fi
rm -rf "${STAGE}"
done
env:
RELEASE_BUILD_JOBS: "3"
run: ./scripts/build-release-archives.sh
- name: Prepare Docker release inputs
run: |
@@ -153,6 +123,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta_release.outputs.tags }}
labels: ${{ steps.meta_release.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Export Docker image archives for release assets
run: |
@@ -162,12 +134,14 @@ jobs:
docker buildx build \
--platform linux/amd64 \
--target runtime-from-dist \
--cache-from type=gha \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_amd64.tar" \
.
docker buildx build \
--platform linux/arm64 \
--target runtime-from-dist \
--cache-from type=gha \
--output type=docker,dest="dist/ds2api_${TAG}_docker_linux_arm64.tar" \
.

View File

@@ -37,7 +37,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths.
- Tool-calling semantics are aligned between Go and Node runtime: the only executable model-output syntax is the canonical XML tool block `<tool_calls>``<invoke name="...">``<parameter name="...">`, plus stream-time anti-leak filtering.
- Tool-calling semantics are aligned between Go and Node runtime: models should output the DSML shell `<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`; DS2API also accepts legacy canonical XML `<tool_calls>``<invoke name="...">``<parameter name="...">`. DSML is normalized back to XML at the parser entry, so internal parsing remains XML-based, with stream-time anti-leak filtering.
- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior.
---
@@ -334,7 +334,8 @@ When `tools` is present, DS2API performs anti-leak handling:
Additional notes:
- The parser currently treats only canonical XML tool blocks (`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`) as executable tool calls. Legacy `<tools>`, `<tool_call>`, `<tool_name>`, `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text.
- The parser treats DSML shell tool blocks (`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`) and legacy canonical XML tool blocks (`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`) as executable tool calls. DSML is normalized back to XML at the parser entry; internal parsing remains XML-based. Legacy `<tools>`, `<tool_call>`, `<tool_name>`, `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text.
- If the final visible response text is empty but the reasoning stream contains an executable tool call, Chat / Responses emits a standard OpenAI `tool_calls` / `function_call` output during finalization. If thinking/reasoning was not enabled by the client, that reasoning text is used only for detection and is not exposed as visible text or `reasoning_content`.
- `tool_calls` shown inside fenced markdown code blocks (for example, ```json ... ```) are treated as examples, not executable calls.
---
@@ -711,7 +712,7 @@ Reads runtime settings and status, including:
- `compat` (`wide_input_strict_output`, `strip_reference_markers`)
- `responses` / `embeddings`
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
- `history_split` (`enabled` always returns `true`, `trigger_after_turns`)
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
- `model_aliases`
- `env_backed`, `needs_vercel_sync`
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
@@ -726,8 +727,9 @@ Hot-updates runtime settings. Supported fields:
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.mode`
- `history_split.trigger_after_turns` (`history_split.enabled` is forced on globally; legacy client writes are stored as `true`)
- `current_input_file.enabled` / `current_input_file.min_chars`
- `model_aliases`
- `history_split` is retained only for legacy config compatibility and no longer affects requests
- `toolcall` policy is fixed and is no longer writable through settings
### `POST /admin/settings/password`
@@ -751,9 +753,9 @@ Imports full config with:
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
Query params `?mode=merge` / `?mode=replace` are also supported.
`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `history_split` via `/admin/settings` or the config file; legacy `toolcall` fields are ignored.
`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `current_input_file` via `/admin/settings` or the config file; `history_split` remains only for legacy compatibility; legacy `toolcall` fields are ignored.
> Note: `merge` mode does not update `compat`, `auto_delete`, or `history_split`.
> Note: `merge` mode does not update `compat`, `auto_delete`, or `current_input_file`.
### `GET /admin/config/export`

49
API.md
View File

@@ -37,7 +37,7 @@
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
- 适配器层职责收敛为:**请求归一化 → DeepSeek 调用 → 协议形态渲染**,减少历史版本中“同能力多处实现”的分叉。
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:当前唯一可执行的模型输出语法是 canonical XML 工具块 `<tool_calls>``<invoke name="...">``<parameter name="...">`,并在流式场景执行防泄漏筛分。
- Tool Calling 的解析策略在 Go 与 Node Runtime 间保持一致:推荐模型输出 DSML 外壳 `<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`;兼容层也接受 DSML wrapper 别名 `<dsml|tool_calls>``<|tool_calls>``<tool_calls>`、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>`),以及旧式 canonical XML `<tool_calls>``<invoke name="...">``<parameter name="...">`。实现上采用窄容错结构扫描:只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 会进入工具路径,裸 `<invoke>` 不计为已支持语法;流式场景继续执行防泄漏筛分。若参数体本身是合法 JSON 字面量(如 `123``true``null`、数组或对象),会按结构化值输出,不再一律当作字符串;若 CDATA 偶发漏闭合,则会在最终 parse / flush 恢复阶段做窄修复,尽量保住已完整包裹的外层工具调用。
- `Admin API` 将配置与运行时策略分开:`/admin/config*` 管静态配置,`/admin/settings*` 管运行时行为。
---
@@ -196,16 +196,22 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
"object": "list",
"data": [
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-pro-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"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-search", "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": []}
]
}
```
> 说明:`/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID常见 alias 仅用于请求入参解析,不会在该接口中单独展开返回。
> 说明:`/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID常见 alias 仅用于请求入参解析,不会在该接口中单独展开返回。带 `-nothinking` 后缀的模型表示无论请求里是否显式开启 thinking / reasoning都会强制关闭思考输出。
### 模型 alias 解析策略
@@ -213,8 +219,9 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
1. 先匹配 DeepSeek 原生模型。
2. 再匹配 `model_aliases` 精确映射。
3. 未命中时按模型家族规则回退(如 `o*``gpt-*``claude-*`
4. 未命中则返回 `invalid_request_error`
3. 如果请求名以 `-nothinking` 结尾,则在最终解析出的规范模型上追加对应的无思考变体
4. 未命中时按模型家族规则回退(如 `o*``gpt-*``claude-*`
5. 仍未命中则返回 `invalid_request_error`
当前内置默认 alias 来自 `internal/config/models.go``config.model_aliases` 会在运行时覆盖或补充同名映射。节选:
@@ -224,6 +231,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
- Gemini`gemini-2.5-pro``gemini-2.5-flash``gemini-pro-vision`
- 其他兼容族:`llama-*``qwen-*``mistral-*``command-*` 会按家族启发式回退
上述 alias 若在请求名后追加 `-nothinking` 后缀,也会映射到对应的强制关闭 thinking 版本。
退役历史模型(如 `claude-1.*``claude-2.*``claude-instant-*``gpt-3.5*`)会被显式拒绝。
### `POST /v1/chat/completions`
@@ -239,7 +248,7 @@ Content-Type: application/json
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias`gpt-5.5``gpt-5.4-mini``gpt-5.3-codex``o3``claude-opus-4-6``claude-sonnet-4-6``gemini-2.5-pro``gemini-2.5-flash` 等) |
| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias`gpt-5.5``gpt-5.4-mini``gpt-5.3-codex``o3``claude-opus-4-6``claude-sonnet-4-6``gemini-2.5-pro``gemini-2.5-flash` 等);若模型名带 `-nothinking` 后缀,则强制关闭 thinking / reasoning |
| `messages` | array | ✅ | OpenAI 风格消息数组 |
| `stream` | boolean | ❌ | 默认 `false` |
| `tools` | array | ❌ | Function Calling 定义 |
@@ -335,7 +344,8 @@ data: [DONE]
补充说明:
- **非代码块上下文**下,工具负载即使与普通文本混合,也会按特征识别并产出可执行 tool call前后普通文本仍可透传
- 解析器当前把 canonical XML 工具块(`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)作为可执行调用解析;旧式 `<tools>``<tool_call>``<tool_name>``<param>``<function_call>``tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。
- 解析器当前把 DSML 外壳(`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`、DSML wrapper 别名(`<dsml|tool_calls>``<|tool_calls>``<tool_calls>`)、常见 DSML 分隔符漏写形态(如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`)、`DSML` 与工具标签名黏连的常见 typo`<DSMLtool_calls>` / `<DSMLinvoke>` / `<DSMLparameter>`)和旧式 canonical XML 工具块(`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`)作为可执行调用解析;DSML 会先归一化回 XML内部仍以 XML 解析语义为准。旧式 `<tools>``<tool_call>``<tool_name>``<param>``<function_call>``tool_use`、antml 风格与纯 JSON `tool_calls` 片段默认都会按普通文本处理。
- 当最终可见正文为空但思维链里包含可执行工具调用时Chat / Responses 会在收尾阶段补发标准 OpenAI `tool_calls` / `function_call` 输出;如果客户端未开启 thinking / reasoning该思维链只用于检测不会作为可见正文或 `reasoning_content` 暴露。
- Markdown fenced code block例如 ```json ... ```)中的 `tool_calls` 仅视为示例文本,不会被执行。
---
@@ -448,16 +458,19 @@ data: [DONE]
"object": "list",
"data": [
{"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-sonnet-4-6-nothinking", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}
{"id": "claude-haiku-4-5-nothinking", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-opus-4-6-nothinking", "object": "model", "created": 1715635200, "owned_by": "anthropic"}
],
"first_id": "claude-opus-4-6",
"last_id": "claude-3-haiku-20240307",
"last_id": "claude-3-haiku-20240307-nothinking",
"has_more": false
}
```
> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x 历史模型 ID 与常见别名。
> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots3.x 历史模型 ID 与常见别名,并为这些可映射模型额外提供 `-nothinking` 变体
### `POST /anthropic/v1/messages`
@@ -475,7 +488,7 @@ anthropic-version: 2023-06-01
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `model` | string | ✅ | 例如 `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-sonnet-4-5``claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID |
| `model` | string | ✅ | 例如 `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5`(兼容 `claude-sonnet-4-5``claude-3-5-haiku-latest`),并支持历史 Claude 模型 ID;若模型名带 `-nothinking` 后缀,则强制关闭 thinking / reasoning |
| `messages` | array | ✅ | Claude 风格消息数组 |
| `max_tokens` | number | ❌ | 缺省自动补 `8192`;当前实现不会硬性截断上游输出 |
| `stream` | boolean | ❌ | 默认 `false` |
@@ -533,7 +546,8 @@ data: {"type":"message_stop"}
**说明**
- 名称中包含 `opus` / `reasoner` / `slow` 的模型会输出 `thinking_delta`
- 默认模型会按各 surface 的既有规则输出 thinking / reasoning 相关增量
-`-nothinking` 后缀的模型会强制关闭 thinking即使请求显式传了 `thinking` / `reasoning` / `reasoning_effort` 也不会输出 `thinking_delta`
- 不会输出 `signature_delta`(上游 DeepSeek 未提供可验证签名)
- `tools` 场景优先避免泄露原始工具 JSON不强制发送 `input_json_delta`
@@ -574,7 +588,7 @@ data: {"type":"message_stop"}
### `POST /v1beta/models/{model}:generateContent`
请求体兼容 Gemini `contents` / `tools` 字段,模型名可用 alias 自动映射到 DeepSeek 模型。
请求体兼容 Gemini `contents` / `tools` 字段,模型名可用 alias 自动映射到 DeepSeek 模型;若路径中的模型名带 `-nothinking` 后缀,则最终会映射到对应的无思考模型
响应为 Gemini 兼容结构,核心字段包括:
@@ -712,7 +726,7 @@ data: {"type":"message_stop"}
- `compat``wide_input_strict_output``strip_reference_markers`
- `responses` / `embeddings`
- `auto_delete``mode``none` / `single` / `all`;旧配置 `sessions=true` 仍按 `all` 处理)
- `history_split``enabled` 固定返回 `true``trigger_after_turns`
- `current_input_file``enabled` 默认返回 `true``min_chars`
- `model_aliases`
- `env_backed``needs_vercel_sync`
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
@@ -727,8 +741,9 @@ data: {"type":"message_stop"}
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.mode`
- `history_split.trigger_after_turns``history_split.enabled` 已全局强制开启;旧客户端传入时会被保存为 `true`
- `current_input_file.enabled` / `current_input_file.min_chars`
- `model_aliases`
- `history_split` 仅作为旧配置兼容字段保留,不再影响请求处理
- `toolcall` 策略已固定,不再作为可写入字段
### `POST /admin/settings/password`
@@ -752,9 +767,9 @@ data: {"type":"message_stop"}
请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。
也支持在查询参数里传 `?mode=merge` / `?mode=replace`
`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys``api_keys``accounts``model_aliases`,并覆盖 `admin``runtime``responses``embeddings` 中的非空字段。`compat``auto_delete``history_split` 建议通过 `/admin/settings` 或配置文件管理;`toolcall` 相关字段会被忽略。
`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys``api_keys``accounts``model_aliases`,并覆盖 `admin``runtime``responses``embeddings` 中的非空字段。`compat``auto_delete``current_input_file` 建议通过 `/admin/settings` 或配置文件管理;`history_split` 仅保留为旧配置兼容字段;`toolcall` 相关字段会被忽略。
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`history_split`。
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`current_input_file`。
### `GET /admin/config/export`

View File

@@ -20,7 +20,7 @@ RUN set -eux; \
GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \
BUILD_VERSION_RESOLVED="${BUILD_VERSION:-}"; \
if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -buildvcs=false -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api
FROM busybox:1.36.1-musl AS busybox-tools
@@ -54,7 +54,6 @@ RUN set -eux; \
test -n "${PKG_DIR}"; \
mkdir -p /out/static; \
cp "${PKG_DIR}/ds2api" /out/ds2api; \
cp "${PKG_DIR}/config.example.json" /out/config.example.json; \
cp -R "${PKG_DIR}/static/admin" /out/static/admin

View File

@@ -4,11 +4,14 @@
# DS2API
<a href="https://trendshift.io/repositories/24508" target="_blank"><img src="https://trendshift.io/api/badge/repositories/24508" alt="CJackHwang%2Fds2api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
[![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases)
[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](docs/DEPLOY.md)
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api)
@@ -122,23 +125,32 @@ flowchart LR
| 模型类型 | 模型 ID | thinking | search |
| --- | --- | --- | --- |
| default | `deepseek-v4-flash` | 默认开启,可由请求参数控制 | ❌ |
| default | `deepseek-v4-flash-nothinking` | 永久关闭,不受请求参数影响 | ❌ |
| expert | `deepseek-v4-pro` | 默认开启,可由请求参数控制 | ❌ |
| expert | `deepseek-v4-pro-nothinking` | 永久关闭,不受请求参数影响 | ❌ |
| default | `deepseek-v4-flash-search` | 默认开启,可由请求参数控制 | ✅ |
| default | `deepseek-v4-flash-search-nothinking` | 永久关闭,不受请求参数影响 | ✅ |
| expert | `deepseek-v4-pro-search` | 默认开启,可由请求参数控制 | ✅ |
| 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 行为以 [API.md](API.md#模型-alias-解析策略) 和 `config.example.json` 为准。
除原生模型外,也支持常见 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` 为准。
### Claude 接口(`GET /anthropic/v1/models`
| 当前常用模型 | 默认映射 |
| --- | --- |
| `claude-sonnet-4-6` | `deepseek-v4-flash` |
| `claude-sonnet-4-6-nothinking` | `deepseek-v4-flash-nothinking` |
| `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest` | `deepseek-v4-flash` |
| `claude-haiku-4-5-nothinking` | `deepseek-v4-flash-nothinking` |
| `claude-opus-4-6` | `deepseek-v4-pro` |
| `claude-opus-4-6-nothinking` | `deepseek-v4-pro-nothinking` |
可通过配置中的 `model_aliases` 覆盖映射关系。
可通过配置中的 `model_aliases` 覆盖映射关系;若请求模型名带 `-nothinking`,会在最终映射结果上强制追加无思考语义
`/anthropic/v1/models` 除上述主别名外,还会返回 Claude 4.x snapshots、3.x 历史模型 ID 与常见 alias便于旧客户端直接兼容。
#### Claude Code 接入避坑(实测)
@@ -146,11 +158,11 @@ flowchart LR
- `ANTHROPIC_BASE_URL` 推荐直接指向 DS2API 根地址(例如 `http://127.0.0.1:5001`Claude Code 会请求 `/v1/messages?beta=true`。
- `ANTHROPIC_API_KEY` 需要与 `config.json` 中 `keys` 一致;建议同时保留常规 key 与 `sk-ant-*` 形态 key兼容不同客户端校验习惯。
- 若系统设置了代理,建议对 DS2API 地址配置 `NO_PROXY=127.0.0.1,localhost,<你的主机IP>`,避免本地回环请求被代理拦截。
- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为当前唯一受支持的 XML 工具块:`<tool_calls><invoke name="..."><parameter name="...">...`,而不是旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use` 或纯 JSON `tool_calls` 片段。
- 如遇“工具调用输出成文本、未执行”问题,请优先检查模型输出是否为推荐的 DSML 工具块:`<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`。兼容层也接受旧式 canonical XML`<tool_calls><invoke name="..."><parameter name="...">...`旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use` 或纯 JSON `tool_calls` 片段不会执行
### Gemini 接口
Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型,支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling`functionDeclarations` → `functionCall` 输出)。
Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型,支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling`functionDeclarations` → `functionCall` 输出)。若 Gemini 模型名带 `-nothinking` 后缀,例如 `gemini-2.5-pro-nothinking`,会映射到对应的强制关闭思考模型。
## 快速开始
@@ -278,7 +290,10 @@ go run ./cmd/ds2api
- `model_aliases`OpenAI / Claude / Gemini 共用的模型 alias 映射。
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
- `history_split`多轮历史拆分策略,已全局强制开启;可调整触发阈值,避免长历史全部内联进 prompt
- `history_split`旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为隐藏上下文文件。
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。
环境变量完整列表见 [部署指南](docs/DEPLOY.md),接口鉴权规则见 [API.md](API.md#鉴权规则)。
@@ -312,14 +327,14 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `
当请求中带 `tools` 时DS2API 会做防泄漏处理与结构化转译:
1. 只在**非代码块上下文**启用执行型 toolcall 识别(代码块示例默认不触发)
2. 解析层当前把 canonical XML 工具块视为可执行调用:`<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`;旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use` / antml 变体与纯 JSON `tool_calls` 片段都会按普通文本处理
2. 解析层当前把 DSML 外壳视为推荐可执行调用:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`;兼容旧式 canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`。DSML 只是外壳别名,内部仍以 XML 解析语义为准;旧式 `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`、`<function_call>`、`tool_use` / antml 变体与纯 JSON `tool_calls` 片段都会按普通文本处理
3. `responses` 流式严格使用官方 item 生命周期事件(`response.output_item.*`、`response.content_part.*`、`response.function_call_arguments.*`
4. `responses` 支持并执行 `tool_choice``auto`/`none`/`required`/强制函数);`required` 违规时非流式返回 `422`,流式返回 `response.failed`
5. 客户端请求哪种协议就按该协议返回工具调用OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML再由兼容层转译
> 说明:当前版本 parser 层仍以“尽量解析成功”为优先,未启用基于 allow-list 的工具名硬拒绝
> 说明:当前版本 parser 层以”尽量解析成功”为优先,所有格式合法的 XML 工具调用都会通过,不做工具名 allow-list 过滤
>
> 想评估把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。
> 想评估把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。
## 本地开发抓包工具
@@ -383,7 +398,7 @@ npm run build --prefix webui
工作流文件:`.github/workflows/release-artifacts.yml`
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`+ `sha256sums.txt`
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`linux/armv7`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`、`windows/arm64`+ `sha256sums.txt`
- **容器镜像发布**:仅推送到 GHCR`ghcr.io/cjackhwang/ds2api`
- **每个压缩包包含**`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback、`config.example.json` 配置示例、README、LICENSE

View File

@@ -4,6 +4,8 @@
# DS2API
<a href="https://trendshift.io/repositories/24508" target="_blank"><img src="https://trendshift.io/api/badge/repositories/24508" alt="CJackHwang%2Fds2api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE)
![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg)
![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg)
@@ -144,7 +146,7 @@ Besides the primary aliases above, `/anthropic/v1/models` also returns Claude 4.
- Set `ANTHROPIC_BASE_URL` to the DS2API root URL (for example `http://127.0.0.1:5001`). Claude Code sends requests to `/v1/messages?beta=true`.
- `ANTHROPIC_API_KEY` must match an entry in `keys` from `config.json`. Keeping both a regular key and an `sk-ant-*` style key improves client compatibility.
- If your environment has proxy variables, set `NO_PROXY=127.0.0.1,localhost,<your_host_ip>` for DS2API to avoid proxy interception of local traffic.
- If tool calls are rendered as plain text and not executed, first verify the model output uses the only supported XML block: `<tool_calls><invoke name="..."><parameter name="...">...`, not legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, or standalone JSON `tool_calls`.
- If tool calls are rendered as plain text and not executed, first verify the model output uses the recommended DSML block: `<|DSML|tool_calls><|DSML|invoke name="..."><|DSML|parameter name="...">...`. DS2API also accepts legacy canonical XML: `<tool_calls><invoke name="..."><parameter name="...">...`; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, or standalone JSON `tool_calls` are not executed.
### Gemini Endpoint
@@ -276,7 +278,9 @@ Common fields:
- `model_aliases`: one shared alias map for OpenAI / Claude / Gemini model names.
- `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`: multi-turn history split policy, now forced on globally; tune its trigger threshold to avoid inlining all long history into the prompt.
- `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.
- 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).
@@ -310,7 +314,7 @@ Queue limit = DS2API_ACCOUNT_MAX_QUEUE (default = recommended concurrency)
When `tools` is present in the request, DS2API performs anti-leak handling:
1. Toolcall feature matching is enabled only in **non-code-block context** (fenced examples are ignored)
2. The parser now treats only the canonical XML wrapper as executable tool-calling syntax: `<tool_calls>``<invoke name="...">``<parameter name="...">`; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text
2. The parser now treats the DSML shell as the recommended executable tool-calling syntax: `<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`; it also accepts legacy canonical XML `<tool_calls>``<invoke name="...">``<parameter name="...">`. DSML is a shell alias and internal parsing remains XML-based; legacy `<tools>` / `<tool_call>` / `<tool_name>` / `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text
3. `responses` streaming strictly uses official item lifecycle events (`response.output_item.*`, `response.content_part.*`, `response.function_call_arguments.*`)
4. `responses` supports and enforces `tool_choice` (`auto`/`none`/`required`/forced function); `required` violations return `422` for non-stream and `response.failed` for stream
5. The output protocol follows the client request (OpenAI / Claude / Gemini native shapes); model-side prompting can prefer XML, and the compatibility layer handles the protocol-specific translation
@@ -379,7 +383,7 @@ npm run build --prefix webui
Workflow: `.github/workflows/release-artifacts.yml`
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`) + `sha256sums.txt`
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `linux/armv7`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`, `windows/arm64`) + `sha256sums.txt`
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), `config.example.json`-based config template, README, LICENSE

View File

@@ -1 +1 @@
4.0.0
4.1.1

View File

@@ -50,9 +50,13 @@
"responses": {
"store_ttl_seconds": 900
},
"history_split": {
"current_input_file": {
"enabled": true,
"trigger_after_turns": 1
"min_chars": 0
},
"thinking_injection": {
"enabled": true,
"prompt": ""
},
"embeddings": {
"provider": "deterministic"

View File

@@ -43,7 +43,7 @@ ds2api/
│ │ ├── responses/ # Responses API and response store
│ │ ├── files/ # Files API and inline-file preprocessing
│ │ ├── embeddings/ # Embeddings API
│ │ ├── history/ # OpenAI history split
│ │ ├── history/ # OpenAI context file handling
│ │ └── shared/ # OpenAI HTTP errors/models/tool formatting
│ ├── js/ # Node runtime related logic
│ │ ├── chat-stream/ # Node streaming bridge
@@ -175,7 +175,7 @@ flowchart LR
- `internal/deepseek/{client,protocol,transport}`: upstream requests, sessions, PoW adaptation, protocol constants, and transport details.
- `internal/js/chat-stream` + `api/chat-stream.js`: Vercel Node streaming bridge; Go prepare/release owns auth, account lease, and completion payload assembly, while Node relays real-time SSE with Go-aligned finalization and tool sieve semantics.
- `internal/stream` + `internal/sse`: Go stream parsing and incremental assembly.
- `internal/toolcall` + `internal/toolstream`: canonical XML tool-call parsing + anti-leak sieve (the only executable format is `<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`).
- `internal/toolcall` + `internal/toolstream`: DSML shell compatibility plus canonical XML tool-call parsing and anti-leak sieve; DSML is normalized back to XML at the entrypoint, and internal parsing remains XML-based.
- `internal/httpapi/admin/*`: Admin API root assembly plus auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version resource packages.
- `internal/chathistory`: server-side conversation history persistence, pagination, detail lookup, and retention policy.
- `internal/config`: config loading/validation + runtime settings hot-reload.

View File

@@ -43,7 +43,7 @@ ds2api/
│ │ ├── responses/ # Responses API 与 response store
│ │ ├── files/ # Files API 与 inline file 预处理
│ │ ├── embeddings/ # Embeddings API
│ │ ├── history/ # OpenAI history split
│ │ ├── history/ # OpenAI context file handling
│ │ └── shared/ # OpenAI HTTP 公共错误/模型/工具格式
│ ├── js/ # Node Runtime 相关逻辑
│ │ ├── chat-stream/ # Node 流式输出桥接
@@ -175,7 +175,7 @@ flowchart LR
- `internal/deepseek/{client,protocol,transport}`上游请求、会话、PoW 适配、协议常量与传输层。
- `internal/js/chat-stream` + `api/chat-stream.js`Vercel Node 流式桥Go prepare/release 管理鉴权、账号租约和 completion payloadNode 侧负责实时 SSE 转发并保持 Go 对齐的终结态和 tool sieve 语义。
- `internal/stream` + `internal/sse`Go 流式解析与增量处理。
- `internal/toolcall` + `internal/toolstream`canonical XML 工具调用解析防泄漏筛分(唯一可执行格式:`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`
- `internal/toolcall` + `internal/toolstream`DSML 外壳兼容与 canonical XML 工具调用解析防泄漏筛分DSML 会在入口归一化回 XML内部仍按 XML 语义解析
- `internal/httpapi/admin/*`Admin API 根装配与 auth/accounts/config/settings/proxies/rawsamples/vercel/history/devcapture/version 等资源子包。
- `internal/chathistory`:服务器端对话记录持久化、分页、单条详情和保留策略。
- `internal/config`:配置加载、校验、运行时 settings 热更新。

View File

@@ -70,9 +70,9 @@ Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
| Platform | Architecture | Format |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
| Linux | amd64, arm64, armv7 | `.tar.gz` |
| macOS | amd64, arm64 | `.tar.gz` |
| Windows | amd64 | `.zip` |
| Windows | amd64, arm64 | `.zip` |
Each archive includes:
@@ -538,7 +538,7 @@ curl -s http://127.0.0.1:5001/readyz
# 3. Model list
curl -s http://127.0.0.1:5001/v1/models
# Expected: {"object":"list","data":[...]}
# Expected: {"object":"list","data":[...]} (including `*-nothinking` variants)
# 4. Admin panel (if WebUI is built)
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5001/admin

View File

@@ -70,9 +70,9 @@ cp config.example.json config.json
| 平台 | 架构 | 文件格式 |
| --- | --- | --- |
| Linux | amd64, arm64 | `.tar.gz` |
| Linux | amd64, arm64, armv7 | `.tar.gz` |
| macOS | amd64, arm64 | `.tar.gz` |
| Windows | amd64 | `.zip` |
| Windows | amd64, arm64 | `.zip` |
每个压缩包包含:
@@ -548,7 +548,7 @@ curl -s http://127.0.0.1:5001/readyz
# 3. 模型列表
curl -s http://127.0.0.1:5001/v1/models
# 预期: {"object":"list","data":[...]}
# 预期: {"object":"list","data":[...]}(包含 `*-nothinking` 变体)
# 4. 管理台页面(如果已构建 WebUI
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5001/admin

112
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,112 @@
# DS2API 开发者速查
语言 / Language: 中文
本文面向维护者和贡献者,用于快速判断“从哪里看、改哪里、跑什么”。架构细节仍以 [ARCHITECTURE.md](./ARCHITECTURE.md) 为准,接口行为以 [API.md](../API.md) 为准。
## 1. 本地入口
常用启动与检查:
```bash
# 后端
go run ./cmd/ds2api
# WebUI 开发服务器
npm run dev --prefix webui
# WebUI 生产构建
npm run build --prefix webui
```
PR 前固定门禁:
```bash
./scripts/lint.sh
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/run-unit-all.sh
npm run build --prefix webui
```
修改 Go 文件后先运行:
```bash
gofmt -w <changed-go-files>
```
## 2. 代码定位
优先从这些入口顺着调用链看:
| 目标 | 入口 |
| --- | --- |
| 总路由、CORS、健康检查 | `internal/server/router.go` |
| OpenAI Chat / Responses | `internal/httpapi/openai/chat``internal/httpapi/openai/responses` |
| Claude / Gemini 兼容入口 | `internal/httpapi/claude``internal/httpapi/gemini` |
| API 请求归一到网页纯文本上下文 | `internal/promptcompat``docs/prompt-compatibility.md` |
| 工具调用解析与流式防泄漏 | `internal/toolcall``internal/toolstream``docs/toolcall-semantics.md` |
| DeepSeek 上游调用、登录、PoW、代理 | `internal/deepseek/client``internal/deepseek/transport` |
| 账号池、并发槽位、等待队列 | `internal/account` |
| Admin API | `internal/httpapi/admin` |
| WebUI 页面 | `webui/src/layout/DashboardShell.jsx``webui/src/features/*` |
| 服务器端对话记录 | `internal/chathistory``internal/httpapi/admin/history` |
## 3. 常见改动建议
- 改接口行为时,同时检查 `API.md` / `API.en.md` 是否需要同步。
- 改 prompt 兼容链路时,必须同步 `docs/prompt-compatibility.md`
- 改 tool call 语义时,同时检查 Go、Node sieve 和 `docs/toolcall-semantics.md`
- 改 WebUI 配置项时,同时检查 `webui/src/features/settings`、语言包和 `config.example.json`
- 拆分大文件时,保持对外函数签名稳定,并跑 `./tests/scripts/check-refactor-line-gate.sh`
## 4. 故障定位
接口请求先看路由入口,再看协议适配层,最后看共享 runtime
1. 路由是否命中:`internal/server/router.go` 和对应 `RegisterRoutes`
2. 鉴权与账号选择:`internal/auth``internal/account`
3. 请求归一化:`internal/promptcompat` 或协议转换包。
4. 上游请求:`internal/deepseek/client`
5. 流式输出:`internal/stream``internal/sse``internal/toolstream`
6. 响应格式:`internal/format/*``internal/translatorcliproxy`
对话记录页面问题优先检查:
- Admin API`/admin/chat-history``/admin/chat-history/{id}`
- 后端存储:`internal/chathistory/store.go`
- 前端轮询和 ETag`webui/src/features/chatHistory/ChatHistoryContainer.jsx`
Tool call 问题优先跑:
```bash
go test -v ./internal/toolcall ./internal/toolstream -count=1
node --test tests/node/stream-tool-sieve.test.js tests/node/chat-stream.test.js
```
## 5. 测试选择
小范围 Go 改动:
```bash
go test ./internal/<package> -count=1
```
前端改动:
```bash
npm run build --prefix webui
```
高风险协议或流式改动:
```bash
./tests/scripts/run-unit-all.sh
```
发布或真实账号链路验证:
```bash
./tests/scripts/run-live.sh
```
端到端测试产物默认写入 `artifacts/testsuite/`。分享日志前需要清理 token、密码、cookie 和原始请求响应内容。

View File

@@ -11,7 +11,8 @@
3. [接口文档API](../API.md)
4. [部署指南](./DEPLOY.md)
5. [测试指南](./TESTING.md)
6. [贡献指南](./CONTRIBUTING.md)
6. [开发者速查](./DEVELOPMENT.md)
7. [贡献指南](./CONTRIBUTING.md)
### 专题文档
@@ -41,7 +42,8 @@ Recommended reading order:
3. [API reference](../API.en.md)
4. [Deployment guide](./DEPLOY.en.md)
5. [Testing guide](./TESTING.md)
6. [Contributing guide](./CONTRIBUTING.en.md)
6. [Developer quick reference](./DEVELOPMENT.md)
7. [Contributing guide](./CONTRIBUTING.en.md)
### Topical docs

View File

@@ -13,6 +13,7 @@ DS2API 提供两个层级的测试:
| 单元测试Go | `./tests/scripts/run-unit-go.sh` | 不需要真实账号 |
| 单元测试Node | `./tests/scripts/run-unit-node.sh` | 不需要真实账号 |
| 单元测试(全部) | `./tests/scripts/run-unit-all.sh` | 不需要真实账号 |
| Release 目标交叉编译 | `./tests/scripts/check-cross-build.sh` | 覆盖发布包支持的 GOOS/GOARCH |
| 端到端测试 | `./tests/scripts/run-live.sh` | 使用真实账号执行全链路测试 |
端到端测试集会录制完整的请求/响应日志,用于故障排查。
@@ -35,6 +36,7 @@ npm run build --prefix webui
- `./scripts/lint.sh` 会运行 Go 格式化检查和 `golangci-lint`;修改 Go 文件后仍建议先执行 `gofmt -w <files>`
- `run-unit-all.sh` 串行调用 Go 与 Node 单元测试入口。
- CI 还会额外在 macOS/Windows 跑 Go 单测,并执行 release 目标交叉编译检查。
- `run-live.sh` 是真实账号端到端测试,适合作为发布或高风险改动后的补充验证,不属于每次 PR 的固定本地门禁。
---
@@ -57,6 +59,7 @@ npm run build --prefix webui
# 结构与流程门禁
./tests/scripts/check-refactor-line-gate.sh
./tests/scripts/check-node-split-syntax.sh
./tests/scripts/check-cross-build.sh
# 历史阶段门禁:阶段 6 手工烟测签字检查(默认读取 plans/stage6-manual-smoke.md
./tests/scripts/check-stage6-manual-smoke.sh

View File

@@ -3,7 +3,7 @@
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [接口文档](../API.md) / [测试指南](./TESTING.md)
> 本文档是 DS2API“把 OpenAI / Claude / Gemini 风格 API 请求兼容成 DeepSeek 网页对话纯文本上下文”的专项说明。
> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、history split、下游 completion payload 组装等行为,都必须同步更新本文档。
> 这是项目最重要的兼容产物之一。凡是修改消息标准化、tool prompt 注入、tool history 保留、文件引用、current input file / legacy history_split、下游 completion payload 组装等行为,都必须同步更新本文档。
## 1. 核心结论
@@ -45,7 +45,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
-> promptcompat 统一消息标准化
-> tool prompt 注入
-> DeepSeek 风格 prompt 拼装
-> 文件收集 / inline 上传 / history splitOpenAI 链路)
-> 文件收集 / inline 上传 / current input fileOpenAI 链路)
-> completion payload
-> 下游网页对话接口
```
@@ -68,6 +68,8 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
[internal/prompt/messages.go](../internal/prompt/messages.go)
- prompt 可见 tool history XML
[internal/prompt/tool_calls.go](../internal/prompt/tool_calls.go)
- 最新 user 思考格式注入:
[internal/promptcompat/thinking_injection.go](../internal/promptcompat/thinking_injection.go)
- completion payload
[internal/promptcompat/standard_request.go](../internal/promptcompat/standard_request.go)
@@ -96,11 +98,22 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
- `prompt` 才是对话上下文主载体。
- `ref_file_ids` 只承载文件引用,不承载普通文本消息。
- `tools` 不会作为“原生工具 schema”直接下发给下游而是被改写进 `prompt`
- 当前 `/v1/chat/completions` 业务路径仍是“每次请求新建一个远端 `chat_session_id`,并默认发送 `parent_message_id: null`”;因此 DS2API 对外默认表现为“新会话 + prompt 拼历史”,而不是复用 DeepSeek 原生会话树。
- 但 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`Claude surface 没有 `thinking` 字段时按 Anthropic 语义视为关闭;Gemini `generationConfig.thinkingConfig.thinkingBudget` 会翻译成同一套 thinking 开关;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。
- 客户端传入的 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 的空回复错误处理之前会默认做一次内部补偿重试:第一次上游完整结束后,如果最终可见正文为空、没有解析到工具调用、也没有已经向客户端流式发出工具调用,并且终止原因不是 `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。
## 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` 自定义提示词,留空时使用内置默认提示词。
这段增强属于 prompt 可见上下文:
- 普通请求会直接出现在最终 `prompt` 的最新 user block 末尾。
- 如果触发 current input file它会进入完整上下文文件中。
### 5.1 角色标记
最终 prompt 使用 DeepSeek 风格角色标记:
@@ -117,17 +130,7 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
实现位置:
[internal/prompt/messages.go](../internal/prompt/messages.go)
### 5.2 thinking continuity 说明
如果启用了 thinking会在最前面额外插入一个 system block提醒模型
- 继续既有会话,不要重开
- earlier messages 是 binding context
- 不要把最终回答只留在 reasoning 里
这部分不是客户端原始消息,而是兼容层主动补进去的连续性契约。
### 5.3 相邻同角色消息会合并
### 5.2 相邻同角色消息会合并
在最终 `MessagesPrepareWithThinking` 中,相邻同 role 的消息会被合并成一个块,中间插入空行。
@@ -144,11 +147,12 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
1. 把每个 tool 的名称、描述、参数 schema 序列化成文本。
2. 拼成 `You have access to these tools:` 大段说明。
3. 再附上统一的 XML tool call 格式约束。
3. 再附上统一的 DSML tool call 外壳格式约束。
4. 把这整段内容并入 system prompt。
工具调用正例仍只示范 canonical XML`<tool_calls>``<invoke name="...">``<parameter name="...">`
提示词会额外强调:如果要调用工具,工具块的首个非空白字符必须就是 `<tool_calls>`,不能只输出 `</tool_calls>` 而漏掉 opening tag
工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>``<|DSML|invoke name="...">``<|DSML|parameter name="...">`
兼容层仍接受旧式纯 `<tool_calls>` wrapper但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command``exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
@@ -182,18 +186,18 @@ assistant 的 reasoning 会变成一个显式标签块:
### 7.2 历史 tool_calls 保留方式
assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON而会转成 prompt 可见的 XML
assistant 历史 `tool_calls` 不会保留成 OpenAI 原生 JSON而会转成 prompt 可见的 DSML 外壳
```xml
<tool_calls>
<invoke name="read_file">
<parameter name="path"><![CDATA[src/main.go]]></parameter>
</invoke>
</tool_calls>
<|DSML|tool_calls>
<|DSML|invoke name="read_file">
<|DSML|parameter name="path"><![CDATA[src/main.go]]></|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>
```
这也是当前项目里唯一受支持的 canonical tool-calling 形态;其他形态都会作为普通文本保留,不会作为可执行调用语法。
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `<invoke ...>` ... `</tool_calls>`,但漏掉最前面的 opening `<tool_calls>`,解析阶段会补回 wrapper 后再尝试识别。
解析层同时兼容旧式纯 XML 形态:`<tool_calls>` / `<invoke>` / `<parameter>`。两者都会先归一到现有 XML 解析语义;其他旧格式都会作为普通文本保留,不会作为可执行调用语法。
例外是 parser 会对一个非常窄的模型失误做修复:如果 assistant 输出了 `<invoke ...>` ... `</tool_calls>`(或 DSML 对应标签),但漏掉最前面的 opening wrapper,解析阶段会补回 wrapper 后再尝试识别。
这件事很重要,因为它决定了:
@@ -236,42 +240,40 @@ OpenAI 文件相关实现:
## 9. 多轮历史为什么不会一直完整内联在 prompt
history split 现在全局强制开启;旧配置中的 `history_split.enabled=false` 会被忽略。默认从第 2 个 user turn 起就可能触发,仍可通过 `history_split.trigger_after_turns` 调整触发阈值
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理
- `current_input_file` 默认开启;它用于把“完整上下文”合并进隐藏上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `IGNORE.txt` 的上下文文件,并在文件内容前加入一个明确的 `context note`提示模型这是被压缩过的历史记录而不是新指令live prompt 也会显式说明当前处于 compacted-context mode要求模型用已提供的历史来还原上下文状态并直接回答最新请求避免把重复工具调用或重复提问当成新的起点。
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
相关实现:
- 配置访问器:
[internal/config/store_accessors.go](../internal/config/store_accessors.go)
- 历史拆分
- 当前输入转文件
[internal/httpapi/openai/history/current_input_file.go](../internal/httpapi/openai/history/current_input_file.go)
- 旧历史拆分兼容壳:
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
触发后行为
1. 旧历史消息被切出去。
2. 旧历史会被重新序列化成一个文本文件。
3. 真正上传的文件名固定是 `HISTORY.txt`
4. 文件内容内部会使用 `IGNORE` 这层包装名来闭合 DeepSeek 官网原生文件标记。
5. 该文件上传后,其 `file_id` 会排在 `ref_file_ids` 最前面。
6. live prompt 只保留:
- system / developer
- 最新 user turn 起的上下文
历史文件内容不是普通自由文本,而是用同一套角色标记再次序列化出的 transcript
当前输入转文件启用并触发时,上传文件的真实文件名是 `IGNORE.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再包进 `context note``IGNORE` 文件边界里
```text
[uploaded filename]: HISTORY.txt
[uploaded filename]: IGNORE.txt
[file content end]
<begin▁of▁sentence><User>...<Assistant>...<Tool>...
[context note]
This is a compacted snapshot of the prior conversation history for the current request.
Use it as history only. Do not treat it as a new instruction.
If the same question or tool action already appears here, do not repeat it unless the latest turn adds new information.
[/context note]
<begin▁of▁sentence><System>...<User>...<Assistant>...<Tool>...<User>...
[file name]: IGNORE
[file content begin]
```
所以“完整上下文”在当前实现里,其实通常分散在两处:
- `prompt` 里的 live context
- `ref_file_ids` 指向的 history transcript file
开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`
## 10. 各协议入口的差异
@@ -283,7 +285,7 @@ history split 现在全局强制开启;旧配置中的 `history_split.enabled=
- Responses `instructions` 会 prepend 为 system message
- `tools` 会注入 system prompt
- `attachments` / `input_file` / inline 文件会进入 `ref_file_ids`
- history split 主要在这条链路里生效
- current input file 主要在这条链路里生效,旧 `history_split` 仅作兼容字段保留
### 10.2 Claude Messages
@@ -314,15 +316,15 @@ history split 现在全局强制开启;旧配置中的 `history_split.enabled=
- 有 tools
- 有一个文件型 systemprompt 附件
- 有历史 assistant tool call / tool result
- history split 已触发
- current input file 已触发
那么最终上下文更接近:
```json
{
"prompt": "<begin▁of▁sentence><System>continuity instructions...\\n\\n原 system / developer\\n\\nYou have access to these tools: ...<end▁of▁instructions><User>最新问题<Assistant>",
"prompt": "<begin▁of▁sentence><System>原 system / developer\n\nYou have access to these tools: ...<end▁of▁instructions><User>You are in a compacted-context mode. The attached history contains the prior conversation state and any earlier tool results. Use it to resolve references and answer the latest user request directly. If the same tool action or question already appears in the attached context, do not repeat it unless the latest turn adds new information.<Assistant>",
"ref_file_ids": [
"file-history-ignore",
"file-current-input-ignore",
"file-systemprompt",
"file-other-attachment"
],
@@ -335,7 +337,7 @@ history split 现在全局强制开启;旧配置中的 `history_split.enabled=
- 大部分结构化语义被压进 `prompt`
- 文件保持文件
- 历史必要时拆文件
- 需要时把完整上下文拆进隐藏上下文文件
## 12. 修改时必须同步本文档的场景
@@ -348,7 +350,8 @@ history split 现在全局强制开启;旧配置中的 `history_split.enabled=
- tool result 注入方式变更
- tool prompt 模板或 tool_choice 约束变更
- inline 文件上传 / 文件引用收集规则变更
- history split 触发条件、上传格式、`IGNORE` 包装格式变更
- current input file 触发条件、上传格式、`IGNORE` 包装格式变更
-`history_split` 兼容逻辑的读取、忽略或退化行为变更
- completion payload 字段语义变更
- Claude / Gemini 对这套统一语义的复用关系变更

View File

@@ -4,9 +4,19 @@
文档导航:[总览](../README.MD) / [架构说明](./ARCHITECTURE.md) / [测试指南](./TESTING.md)
## 1) 当前唯一可执行格式
## 1) 当前可执行格式
当前版本只把下面这类 canonical XML 视为可执行工具调用
当前版本推荐模型输出 DSML 外壳
```xml
<|DSML|tool_calls>
<|DSML|invoke name="read_file">
<|DSML|parameter name="path"><![CDATA[README.MD]]></|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>
```
兼容层仍接受旧式 canonical XML
```xml
<tool_calls>
@@ -16,21 +26,26 @@
</tool_calls>
```
这不是原生 DSML 全链路实现。DSML 只作为 prompt 外壳和解析入口别名;进入 parser 前会被归一化成 `<tool_calls>` / `<invoke>` / `<parameter>`,内部仍以现有 XML 解析语义为准。
约束:
- 必须有 `<tool_calls>...</tool_calls>` wrapper
- 每个调用必须在 `<invoke name="...">...</invoke>`
- 必须有 `<|DSML|tool_calls>...</|DSML|tool_calls>``<tool_calls>...</tool_calls>` wrapper
- 每个调用必须在 `<|DSML|invoke name="...">...</|DSML|invoke>``<invoke name="...">...</invoke>`
- 工具名必须放在 `invoke``name` 属性
- 参数必须使用 `<parameter name="...">...</parameter>`
- 参数必须使用 `<|DSML|parameter name="...">...</|DSML|parameter>``<parameter name="...">...</parameter>`
- 同一个工具块内不要混用 DSML 标签和旧 XML 工具标签;混搭会被视为非法工具块
兼容修复:
- 如果模型漏掉 opening `<tool_calls>`,但后面仍输出了一个或多个 `<invoke ...>` 并以 `</tool_calls>` 收尾Go 解析链路会在解析前补回缺失的 opening wrapper。
- 这是一个针对常见模型失误的窄修复不改变推荐输出格式prompt 仍要求模型直接输出完整 canonical XML
- 如果模型漏掉 opening wrapper,但后面仍输出了一个或多个 invoke 并以 closing wrapper 收尾Go 解析链路会在解析前补回缺失的 opening wrapper。
- 如果模型把 DSML 标签里的分隔符 `|` 写漏成空格(例如 `<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`,或无 leading pipe 的 `<DSML tool_calls>` 形态),或把 `DSML` 与工具标签名直接黏连(例如 `<DSMLtool_calls>` / `<DSMLinvoke>` / `<DSMLparameter>`Go / Node 会在固定工具标签名范围内归一化;相似但非工具标签名(如 `tool_calls_extra`)仍按普通文本处理
- 这是一个针对常见模型失误的窄修复不改变推荐输出格式prompt 仍要求模型直接输出完整 DSML 外壳。
-`<invoke ...>` / `<parameter ...>` 不会被当成“已支持的工具语法”;只有 `tool_calls` wrapper 或可修复的缺失 opening wrapper 才会进入工具调用路径。
## 2) 非 canonical 内容
## 2) 非兼容内容
任何不满足上述 canonical XML 形态的内容,都会保留为普通文本,不会执行。一个例外是上一节提到的“缺失 opening `<tool_calls>`、但 closing `</tool_calls>` 仍存在”的窄修复场景。
任何不满足上述 DSML / canonical XML 形态的内容,都会保留为普通文本,不会执行。一个例外是上一节提到的“缺失 opening wrapper、但 closing wrapper 仍存在”的窄修复场景。
当前 parser 不把 allow-list 当作硬安全边界即使传入了已声明工具名列表XML 里出现未声明工具名时也会尽量解析并交给上层协议输出;真正的执行侧仍必须自行校验工具名和参数。
@@ -38,25 +53,32 @@
在流式链路中Go / Node 一致):
- canonical `<tool_calls>` wrapper 会进入结构化捕获
- 如果流里直接从 `<invoke ...>` 开始,但后面补上了 `</tool_calls>`Go 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复
- DSML `<|DSML|tool_calls>` wrapper、兼容变体`<dsml|tool_calls>``<tool_calls>``<|tool_calls>`)、窄容错空格分隔形态(如 `<|DSML tool_calls>`)、黏连形态(如 `<DSMLtool_calls>`)和 canonical `<tool_calls>` wrapper 会进入结构化捕获
- 如果流里直接从 invoke 开始,但后面补上了 closing wrapperGo 流式筛分也会按缺失 opening wrapper 的修复路径尝试恢复
- 已识别成功的工具调用不会再次回流到普通文本
- 不符合新格式的块不会执行,并继续按原样文本透传
- fenced code block 中的 XML 示例始终按普通文本处理
- fenced code block(反引号 `` ``` `` 和波浪线 `~~~`中的 XML 示例始终按普通文本处理
- 支持嵌套围栏(如 4 反引号嵌套 3 反引号)和 CDATA 内围栏保护
- 如果模型把 `<![CDATA[` 打开后却没有闭合,流式扫描阶段仍会保守地继续缓冲,不会误把 CDATA 里的示例 XML 当成真实工具调用;在最终 parse / flush 恢复阶段,会对这类 loose CDATA 做窄修复,尽量保住外层已完整包裹的真实工具调用
- 当文本中 mention 了某种标签名(如 `<dsml|tool_calls>` 或 Markdown inline code 里的 `<|DSML|tool_calls>`而后面紧跟真正工具调用时sieve 会跳过不可解析的 mention 候选并继续匹配后续真实工具块,不会因 mention 导致工具调用丢失,也不会截断 mention 后的正文
另外,`<parameter>` 的值如果本身是合法 JSON 字面量,也会按结构化值解析,而不是一律保留为字符串。例如 `123``true``null``[1,2]``{"a":1}` 都会还原成对应的 number / boolean / null / array / object。
结构化 XML 参数也会还原为 JSON 结构:如果参数体只包含一个或多个 `<item>...</item>` 子节点,会输出数组;嵌套对象里的 item-only 字段也同样按数组处理。例如 `<parameter name="questions"><item><question>...</question></item></parameter>` 会输出 `{"questions":[{"question":"..."}]}`,而不是 `{"questions":{"item":...}}`
如果模型误把完整结构化 XML fragment 放进 CDATAGo / Node 会先保护明显的原文字段(如 `content` / `command` / `prompt` / `old_string` / `new_string`),其余参数会尝试把 CDATA 内的完整 XML fragment 还原成 object / array常见的 `<br>` 分隔符会按换行归一化后再解析。
## 4) 输出结构
`ParseToolCallsDetailed` / `parseToolCallsDetailed` 返回:
- `calls`:解析出的工具调用列表(`name` + `input`
- `sawToolCallSyntax`:检测到 canonical wrapper或命中“缺失 opening wrapper 但可修复”的形态时会为 `true`
- `sawToolCallSyntax`:检测到 DSML / canonical wrapper或命中“缺失 opening wrapper 但可修复”的形态时会为 `true`;裸 `invoke` 不计入该标记
- `rejectedByPolicy`:当前固定为 `false`
- `rejectedToolNames`:当前固定为空数组
## 5) 落地建议
1. Prompt 里只示范 canonical XML 语法。
2. 上游客户端应直接输出 canonical XMLDS2API 只对“closing tag 在、opening tag 漏掉”的常见失误做窄修复,不会泛化接受其他旧格式。
1. Prompt 里只示范 DSML 外壳语法。
2. 上游客户端应直接输出完整 DSML 外壳DS2API 兼容旧式 canonical XML,并只对“closing tag 在、opening tag 漏掉”的常见失误做窄修复,不会泛化接受其他旧格式。
3. 不要依赖 parser 做安全控制;执行器侧仍应做工具名和参数校验。
## 6) 回归验证
@@ -70,6 +92,12 @@ node --test tests/node/stream-tool-sieve.test.js
重点覆盖:
- canonical `<tool_calls>` wrapper 正常解析
- canonical 内容按普通文本透传
- DSML `<|DSML|tool_calls>` wrapper 正常解析
- legacy canonical `<tool_calls>` wrapper 正常解析
- 别名变体(`<dsml|tool_calls>``<tool_calls>``<|tool_calls>`、DSML 空格分隔 typo`<|DSML tool_calls>`)和黏连 typo`<DSMLtool_calls>`)正常解析
- 混搭标签DSML wrapper + canonical inner归一化后正常解析
- 波浪线围栏 `~~~` 内的示例不执行
- 嵌套围栏4 反引号嵌套 3 反引号)内的示例不执行
- 文本 mention 标签名后紧跟真正工具调用的场景(含同一 wrapper 变体)
- 非兼容内容按普通文本透传
- 代码块示例不执行

View File

@@ -192,6 +192,18 @@ func (s *Store) Snapshot() (File, error) {
return cloneFile(s.state), nil
}
func (s *Store) Revision() (int64, error) {
if s == nil {
return 0, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return 0, s.err
}
return s.state.Revision, nil
}
func (s *Store) Enabled() bool {
if s == nil {
return false
@@ -220,6 +232,22 @@ func (s *Store) Get(id string) (Entry, error) {
return cloneEntry(item), nil
}
func (s *Store) DetailRevision(id string) (int64, error) {
if s == nil {
return 0, errors.New("chat history store is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return 0, s.err
}
item, ok := s.details[strings.TrimSpace(id)]
if !ok {
return 0, errors.New("chat history entry not found")
}
return item.Revision, nil
}
func (s *Store) Start(params StartParams) (Entry, error) {
if s == nil {
return Entry{}, errors.New("chat history store is nil")

View File

@@ -48,6 +48,12 @@ func (c Config) MarshalJSON() ([]byte, error) {
if c.HistorySplit.Enabled != nil || c.HistorySplit.TriggerAfterTurns != nil {
m["history_split"] = c.HistorySplit
}
if c.CurrentInputFile.Enabled != nil || c.CurrentInputFile.MinChars != 0 {
m["current_input_file"] = c.CurrentInputFile
}
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
m["thinking_injection"] = c.ThinkingInjection
}
if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash
}
@@ -118,6 +124,14 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.HistorySplit); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "current_input_file":
if err := json.Unmarshal(v, &c.CurrentInputFile); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "thinking_injection":
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -157,6 +171,14 @@ func (c Config) Clone() Config {
Enabled: cloneBoolPtr(c.HistorySplit.Enabled),
TriggerAfterTurns: cloneIntPtr(c.HistorySplit.TriggerAfterTurns),
},
CurrentInputFile: CurrentInputFileConfig{
Enabled: cloneBoolPtr(c.CurrentInputFile.Enabled),
MinChars: c.CurrentInputFile.MinChars,
},
ThinkingInjection: ThinkingInjectionConfig{
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
Prompt: c.ThinkingInjection.Prompt,
},
VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{},

View File

@@ -8,21 +8,23 @@ import (
)
type Config struct {
Keys []string `json:"keys,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
Admin AdminConfig `json:"admin,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
HistorySplit HistorySplitConfig `json:"history_split"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
Keys []string `json:"keys,omitempty"`
APIKeys []APIKey `json:"api_keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
Admin AdminConfig `json:"admin,omitempty"`
Runtime RuntimeConfig `json:"runtime,omitempty"`
Compat CompatConfig `json:"compat,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
AutoDelete AutoDeleteConfig `json:"auto_delete"`
HistorySplit HistorySplitConfig `json:"history_split"`
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"`
}
type Account struct {
@@ -100,7 +102,6 @@ func (c *Config) NormalizeCredentials() {
}
c.normalizeModelAliases()
c.forceHistorySplitEnabled()
}
// DropInvalidAccounts removes accounts that cannot be addressed by admin APIs
@@ -141,14 +142,6 @@ func (c *Config) normalizeModelAliases() {
}
}
func (c *Config) forceHistorySplitEnabled() {
if c == nil {
return
}
enabled := true
c.HistorySplit.Enabled = &enabled
}
type CompatConfig struct {
WideInputStrictOutput *bool `json:"wide_input_strict_output,omitempty"`
StripReferenceMarkers *bool `json:"strip_reference_markers,omitempty"`
@@ -184,3 +177,13 @@ type HistorySplitConfig struct {
Enabled *bool `json:"enabled,omitempty"`
TriggerAfterTurns *int `json:"trigger_after_turns,omitempty"`
}
type CurrentInputFileConfig struct {
Enabled *bool `json:"enabled,omitempty"`
MinChars int `json:"min_chars,omitempty"`
}
type ThinkingInjectionConfig struct {
Enabled *bool `json:"enabled,omitempty"`
Prompt string `json:"prompt,omitempty"`
}

View File

@@ -19,6 +19,16 @@ func TestGetModelConfigDeepSeekChat(t *testing.T) {
}
}
func TestGetModelConfigDeepSeekChatNoThinking(t *testing.T) {
thinking, search, ok := GetModelConfig("deepseek-v4-flash-nothinking")
if !ok {
t.Fatal("expected ok for deepseek-v4-flash-nothinking")
}
if thinking || search {
t.Fatalf("expected thinking=false search=false for deepseek-v4-flash-nothinking, got thinking=%v search=%v", thinking, search)
}
}
func TestGetModelConfigDeepSeekReasoner(t *testing.T) {
thinking, search, ok := GetModelConfig("deepseek-v4-pro")
if !ok {
@@ -84,6 +94,10 @@ func TestGetModelTypeDefaultExpertAndVision(t *testing.T) {
if !ok || defaultType != "default" {
t.Fatalf("expected default model_type, got ok=%v model_type=%q", ok, defaultType)
}
defaultNoThinkingType, ok := GetModelType("deepseek-v4-flash-nothinking")
if !ok || defaultNoThinkingType != "default" {
t.Fatalf("expected default model_type for nothinking, got ok=%v model_type=%q", ok, defaultNoThinkingType)
}
expertType, ok := GetModelType("deepseek-v4-pro")
if !ok || expertType != "expert" {
t.Fatalf("expected expert model_type, got ok=%v model_type=%q", ok, expertType)
@@ -734,12 +748,18 @@ func TestOpenAIModelsResponse(t *testing.T) {
t.Fatal("expected non-empty models list")
}
expected := map[string]bool{
"deepseek-v4-flash": false,
"deepseek-v4-pro": false,
"deepseek-v4-flash-search": false,
"deepseek-v4-pro-search": false,
"deepseek-v4-vision": false,
"deepseek-v4-vision-search": 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,
"deepseek-v4-vision-search": false,
"deepseek-v4-vision-search-nothinking": false,
}
for _, model := range data {
if _, ok := expected[model.ID]; ok {

View File

@@ -13,6 +13,13 @@ func TestResolveModelDirectDeepSeek(t *testing.T) {
}
}
func TestResolveModelDirectDeepSeekNoThinking(t *testing.T) {
got, ok := ResolveModel(nil, "deepseek-v4-flash-nothinking")
if !ok || got != "deepseek-v4-flash-nothinking" {
t.Fatalf("expected deepseek-v4-flash-nothinking, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelAlias(t *testing.T) {
got, ok := ResolveModel(nil, "gpt-4.1")
if !ok || got != "deepseek-v4-flash" {
@@ -34,6 +41,13 @@ func TestResolveLatestClaudeAlias(t *testing.T) {
}
}
func TestResolveLatestClaudeAliasNoThinking(t *testing.T) {
got, ok := ResolveModel(nil, "claude-sonnet-4-6-nothinking")
if !ok || got != "deepseek-v4-flash-nothinking" {
t.Fatalf("expected alias claude-sonnet-4-6-nothinking -> deepseek-v4-flash-nothinking, got ok=%v model=%q", ok, got)
}
}
func TestResolveExpandedHistoricalAliases(t *testing.T) {
cases := []struct {
name string
@@ -68,6 +82,13 @@ func TestResolveModelHeuristicReasoner(t *testing.T) {
}
}
func TestResolveModelHeuristicReasonerNoThinking(t *testing.T) {
got, ok := ResolveModel(nil, "o3-super-nothinking")
if !ok || got != "deepseek-v4-pro-nothinking" {
t.Fatalf("expected heuristic reasoner nothinking, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelUnknown(t *testing.T) {
_, ok := ResolveModel(nil, "totally-custom-model")
if ok {

View File

@@ -14,7 +14,9 @@ type ModelAliasReader interface {
ModelAliases() map[string]string
}
var DeepSeekModels = []ModelInfo{
const noThinkingModelSuffix = "-nothinking"
var deepSeekBaseModels = []ModelInfo{
{ID: "deepseek-v4-flash", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
{ID: "deepseek-v4-pro", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
{ID: "deepseek-v4-flash-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
@@ -23,7 +25,9 @@ var DeepSeekModels = []ModelInfo{
{ID: "deepseek-v4-vision-search", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
}
var ClaudeModels = []ModelInfo{
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels)
var claudeBaseModels = []ModelInfo{
// Current aliases
{ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
{ID: "claude-sonnet-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
@@ -53,19 +57,26 @@ var ClaudeModels = []ModelInfo{
{ID: "claude-3-haiku-20240307", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
}
var ClaudeModels = appendNoThinkingVariants(claudeBaseModels)
func GetModelConfig(model string) (thinking bool, search bool, ok bool) {
switch lower(model) {
baseModel, noThinking := splitNoThinkingModel(model)
if baseModel == "" {
return false, false, false
}
switch baseModel {
case "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-vision":
return true, false, true
return !noThinking, false, true
case "deepseek-v4-flash-search", "deepseek-v4-pro-search", "deepseek-v4-vision-search":
return true, true, true
return !noThinking, true, true
default:
return false, false, false
}
}
func GetModelType(model string) (modelType string, ok bool) {
switch lower(model) {
baseModel, _ := splitNoThinkingModel(model)
switch baseModel {
case "deepseek-v4-flash", "deepseek-v4-flash-search":
return "default", true
case "deepseek-v4-pro", "deepseek-v4-pro-search":
@@ -82,6 +93,11 @@ func IsSupportedDeepSeekModel(model string) bool {
return ok
}
func IsNoThinkingModel(model string) bool {
_, noThinking := splitNoThinkingModel(model)
return noThinking
}
func DefaultModelAliases() map[string]string {
return map[string]string{
// OpenAI GPT / ChatGPT families
@@ -191,62 +207,19 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) {
if model == "" {
return "", false
}
if isRetiredHistoricalModel(model) {
return "", false
}
aliases := loadModelAliases(store)
if IsSupportedDeepSeekModel(model) {
return model, true
}
aliases := DefaultModelAliases()
if store != nil {
for k, v := range store.ModelAliases() {
aliases[lower(strings.TrimSpace(k))] = lower(strings.TrimSpace(v))
}
}
if mapped, ok := aliases[model]; ok && IsSupportedDeepSeekModel(mapped) {
return mapped, true
}
if strings.HasPrefix(model, "deepseek-") {
baseModel, noThinking := splitNoThinkingModel(model)
resolvedModel, ok := resolveCanonicalModel(aliases, baseModel)
if !ok {
return "", false
}
knownFamily := false
for _, prefix := range []string{
"gpt-", "o1", "o3", "claude-", "gemini-", "llama-", "qwen-", "mistral-", "command-",
} {
if strings.HasPrefix(model, prefix) {
knownFamily = true
break
}
}
if !knownFamily {
return "", false
}
useVision := strings.Contains(model, "vision")
useReasoner := strings.Contains(model, "reason") ||
strings.Contains(model, "reasoner") ||
strings.HasPrefix(model, "o1") ||
strings.HasPrefix(model, "o3") ||
strings.Contains(model, "opus") ||
strings.Contains(model, "slow") ||
strings.Contains(model, "r1")
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:
return "deepseek-v4-pro-search", true
case useReasoner:
return "deepseek-v4-pro", true
case useSearch:
return "deepseek-v4-flash-search", true
default:
return "deepseek-v4-flash", true
}
return withNoThinkingVariant(resolvedModel, noThinking), true
}
func isRetiredHistoricalModel(model string) bool {
@@ -303,3 +276,100 @@ func ClaudeModelsResponse() map[string]any {
resp["has_more"] = false
return resp
}
func appendNoThinkingVariants(models []ModelInfo) []ModelInfo {
out := make([]ModelInfo, 0, len(models)*2)
for _, model := range models {
out = append(out, model)
variant := model
variant.ID = withNoThinkingVariant(model.ID, true)
out = append(out, variant)
}
return out
}
func splitNoThinkingModel(model string) (string, bool) {
model = lower(strings.TrimSpace(model))
if strings.HasSuffix(model, noThinkingModelSuffix) {
return strings.TrimSuffix(model, noThinkingModelSuffix), true
}
return model, false
}
func withNoThinkingVariant(model string, enabled bool) string {
baseModel, _ := splitNoThinkingModel(model)
if !enabled {
return baseModel
}
if baseModel == "" {
return ""
}
return baseModel + noThinkingModelSuffix
}
func loadModelAliases(store ModelAliasReader) map[string]string {
aliases := DefaultModelAliases()
if store != nil {
for k, v := range store.ModelAliases() {
aliases[lower(strings.TrimSpace(k))] = lower(strings.TrimSpace(v))
}
}
return aliases
}
func resolveCanonicalModel(aliases map[string]string, model string) (string, bool) {
model = lower(strings.TrimSpace(model))
if model == "" {
return "", false
}
if isRetiredHistoricalModel(model) {
return "", false
}
if IsSupportedDeepSeekModel(model) {
return model, true
}
if mapped, ok := aliases[model]; ok && IsSupportedDeepSeekModel(mapped) {
return mapped, true
}
if strings.HasPrefix(model, "deepseek-") {
return "", false
}
knownFamily := false
for _, prefix := range []string{
"gpt-", "o1", "o3", "claude-", "gemini-", "llama-", "qwen-", "mistral-", "command-",
} {
if strings.HasPrefix(model, prefix) {
knownFamily = true
break
}
}
if !knownFamily {
return "", false
}
useVision := strings.Contains(model, "vision")
useReasoner := strings.Contains(model, "reason") ||
strings.Contains(model, "reasoner") ||
strings.HasPrefix(model, "o1") ||
strings.HasPrefix(model, "o3") ||
strings.Contains(model, "opus") ||
strings.Contains(model, "slow") ||
strings.Contains(model, "r1")
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:
return "deepseek-v4-pro-search", true
case useReasoner:
return "deepseek-v4-pro", true
case useSearch:
return "deepseek-v4-flash-search", true
default:
return "deepseek-v4-flash", true
}
}

View File

@@ -164,14 +164,39 @@ func (s *Store) AutoDeleteSessions() bool {
}
func (s *Store) HistorySplitEnabled() bool {
return true
return false
}
func (s *Store) HistorySplitTriggerAfterTurns() int {
return 1
}
func (s *Store) CurrentInputFileEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.HistorySplit.TriggerAfterTurns == nil || *s.cfg.HistorySplit.TriggerAfterTurns <= 0 {
return 1
if s.cfg.CurrentInputFile.Enabled == nil {
return true
}
return *s.cfg.HistorySplit.TriggerAfterTurns
return *s.cfg.CurrentInputFile.Enabled
}
func (s *Store) CurrentInputFileMinChars() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.CurrentInputFile.MinChars
}
func (s *Store) ThinkingInjectionEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.ThinkingInjection.Enabled == nil {
return true
}
return *s.cfg.ThinkingInjection.Enabled
}
func (s *Store) ThinkingInjectionPrompt() string {
s.mu.RLock()
defer s.mu.RUnlock()
return strings.TrimSpace(s.cfg.ThinkingInjection.Prompt)
}

View File

@@ -3,40 +3,65 @@ package config
import "testing"
func TestStoreHistorySplitAccessors(t *testing.T) {
store := &Store{cfg: Config{}}
if !store.HistorySplitEnabled() {
t.Fatal("expected history split enabled by default")
enabled := true
turns := 3
store := &Store{cfg: Config{HistorySplit: HistorySplitConfig{
Enabled: &enabled,
TriggerAfterTurns: &turns,
}}}
if store.HistorySplitEnabled() {
t.Fatal("expected history split to stay disabled")
}
if got := store.HistorySplitTriggerAfterTurns(); got != 1 {
t.Fatalf("default history split trigger_after_turns=%d want=1", got)
t.Fatalf("history split trigger_after_turns=%d want=1", got)
}
}
func TestStoreCurrentInputFileAccessors(t *testing.T) {
store := &Store{cfg: Config{}}
if !store.CurrentInputFileEnabled() {
t.Fatal("expected current input file enabled by default")
}
if got := store.CurrentInputFileMinChars(); got != 0 {
t.Fatalf("default current input file min_chars=%d want=0", got)
}
enabled := false
turns := 3
store.cfg.HistorySplit = HistorySplitConfig{
Enabled: &enabled,
TriggerAfterTurns: &turns,
store.cfg.CurrentInputFile = CurrentInputFileConfig{Enabled: &enabled, MinChars: 12345}
if store.CurrentInputFileEnabled() {
t.Fatal("expected current input file disabled")
}
if !store.HistorySplitEnabled() {
t.Fatal("expected history split to stay enabled after legacy disabled override")
enabled = true
store.cfg.CurrentInputFile.Enabled = &enabled
if !store.CurrentInputFileEnabled() {
t.Fatal("expected current input file enabled")
}
if got := store.HistorySplitTriggerAfterTurns(); got != 3 {
t.Fatalf("history split trigger_after_turns=%d want=3", got)
if got := store.CurrentInputFileMinChars(); got != 12345 {
t.Fatalf("current input file min_chars=%d want=12345", got)
}
historyEnabled := true
store.cfg.HistorySplit.Enabled = &historyEnabled
if !store.CurrentInputFileEnabled() {
t.Fatal("expected history split config to not suppress current input file mode")
}
}
func TestStoreHistorySplitLegacyDisabledConfigNormalizesToEnabled(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"history_split":{"enabled":false,"trigger_after_turns":2}}`)
store := LoadStore()
if !store.HistorySplitEnabled() {
t.Fatal("expected history split enabled when legacy config disables it")
func TestStoreThinkingInjectionAccessors(t *testing.T) {
store := &Store{cfg: Config{}}
if !store.ThinkingInjectionEnabled() {
t.Fatal("expected thinking injection enabled by default")
}
snap := store.Snapshot()
if snap.HistorySplit.Enabled == nil || !*snap.HistorySplit.Enabled {
t.Fatalf("expected normalized history_split.enabled=true, got %#v", snap.HistorySplit.Enabled)
disabled := false
store.cfg.ThinkingInjection.Enabled = &disabled
if store.ThinkingInjectionEnabled() {
t.Fatal("expected thinking injection disabled by explicit config")
}
if got := store.HistorySplitTriggerAfterTurns(); got != 2 {
t.Fatalf("history split trigger_after_turns=%d want=2", got)
store.cfg.ThinkingInjection.Prompt = " custom thinking prompt "
if got := store.ThinkingInjectionPrompt(); got != "custom thinking prompt" {
t.Fatalf("thinking injection prompt=%q want custom thinking prompt", got)
}
}

View File

@@ -24,7 +24,7 @@ func ValidateConfig(c Config) error {
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
return err
}
if err := ValidateHistorySplitConfig(c.HistorySplit); err != nil {
if err := ValidateCurrentInputFileConfig(c.CurrentInputFile); err != nil {
return err
}
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
@@ -114,11 +114,9 @@ func ValidateAutoDeleteConfig(autoDelete AutoDeleteConfig) error {
return ValidateAutoDeleteMode(autoDelete.Mode)
}
func ValidateHistorySplitConfig(historySplit HistorySplitConfig) error {
if historySplit.TriggerAfterTurns != nil {
if err := ValidateIntRange("history_split.trigger_after_turns", *historySplit.TriggerAfterTurns, 1, 1000, true); err != nil {
return err
}
func ValidateCurrentInputFileConfig(currentInputFile CurrentInputFileConfig) error {
if currentInputFile.MinChars != 0 {
return ValidateIntRange("current_input_file.min_chars", currentInputFile.MinChars, 1, 100000000, true)
}
return nil
}

View File

@@ -40,11 +40,9 @@ func TestValidateConfigRejectsInvalidValues(t *testing.T) {
want: "auto_delete.mode",
},
{
name: "history split",
cfg: Config{HistorySplit: HistorySplitConfig{
TriggerAfterTurns: intPtr(0),
}},
want: "history_split.trigger_after_turns",
name: "current input file",
cfg: Config{CurrentInputFile: CurrentInputFileConfig{MinChars: -1}},
want: "current_input_file.min_chars",
},
}
@@ -66,5 +64,3 @@ func TestValidateConfigAcceptsLegacyAutoDeleteSessions(t *testing.T) {
t.Fatalf("expected legacy auto_delete.sessions config to remain valid, got %v", err)
}
}
func intPtr(v int) *int { return &v }

View File

@@ -3,6 +3,7 @@ package protocol
import (
_ "embed"
"encoding/json"
"fmt"
)
const (
@@ -21,15 +22,11 @@ const (
DeepSeekUploadTargetPath = "/api/v0/file/upload_file"
)
var defaultBaseHeaders = map[string]string{
"Host": "chat.deepseek.com",
"User-Agent": "DeepSeek/1.8.0 Android/35",
"Accept": "application/json",
"Content-Type": "application/json",
"x-client-platform": "android",
"x-client-version": "1.8.0",
"x-client-locale": "zh_CN",
"accept-charset": "UTF-8",
var defaultStaticBaseHeaders = map[string]string{
"Host": "chat.deepseek.com",
"Accept": "application/json",
"Content-Type": "application/json",
"accept-charset": "UTF-8",
}
var defaultSkipContainsPatterns = []string{
@@ -47,11 +44,21 @@ var defaultSkipExactPaths = []string{
"response/search_status",
}
var BaseHeaders = cloneStringMap(defaultBaseHeaders)
var ClientVersion string
var BaseHeaders = map[string]string{}
var SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns)
var SkipExactPathSet = toStringSet(defaultSkipExactPaths)
type clientConstants struct {
Name string `json:"name"`
Platform string `json:"platform"`
Version string `json:"version"`
AndroidAPILevel string `json:"android_api_level"`
Locale string `json:"locale"`
}
type sharedConstants struct {
Client clientConstants `json:"client"`
BaseHeaders map[string]string `json:"base_headers"`
SkipContainsPattern []string `json:"skip_contains_patterns"`
SkipExactPaths []string `json:"skip_exact_paths"`
@@ -63,19 +70,68 @@ var sharedConstantsJSON []byte
func init() {
cfg := sharedConstants{}
if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil {
return
}
if len(cfg.BaseHeaders) > 0 {
BaseHeaders = cloneStringMap(cfg.BaseHeaders)
panic(fmt.Errorf("load DeepSeek shared constants: %w", err))
}
applySharedConstants(cfg)
}
func applySharedConstants(cfg sharedConstants) {
client := normalizeClientConstants(cfg.Client)
ClientVersion = client.Version
BaseHeaders = buildBaseHeaders(client, cfg.BaseHeaders)
SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns)
if len(cfg.SkipContainsPattern) > 0 {
SkipContainsPatterns = cloneStringSlice(cfg.SkipContainsPattern)
}
SkipExactPathSet = toStringSet(defaultSkipExactPaths)
if len(cfg.SkipExactPaths) > 0 {
SkipExactPathSet = toStringSet(cfg.SkipExactPaths)
}
}
func normalizeClientConstants(in clientConstants) clientConstants {
if in.Name == "" {
in.Name = "DeepSeek"
}
if in.Platform == "" {
in.Platform = "android"
}
if in.AndroidAPILevel == "" {
in.AndroidAPILevel = "35"
}
if in.Locale == "" {
in.Locale = "zh_CN"
}
return in
}
func buildBaseHeaders(client clientConstants, overrides map[string]string) map[string]string {
out := cloneStringMap(defaultStaticBaseHeaders)
for k, v := range overrides {
if k == "" || v == "" {
continue
}
out[k] = v
}
if client.Name != "" && client.Version != "" {
userAgent := client.Name + "/" + client.Version
if client.Platform == "android" && client.AndroidAPILevel != "" {
userAgent += " Android/" + client.AndroidAPILevel
}
out["User-Agent"] = userAgent
}
if client.Platform != "" {
out["x-client-platform"] = client.Platform
}
if client.Version != "" {
out["x-client-version"] = client.Version
}
if client.Locale != "" {
out["x-client-locale"] = client.Locale
}
return out
}
func cloneStringMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
@@ -103,6 +159,6 @@ func toStringSet(in []string) map[string]struct{} {
const (
KeepAliveTimeout = 5
StreamIdleTimeout = 30
StreamIdleTimeout = 90
MaxKeepaliveCount = 10
)

View File

@@ -1,11 +1,15 @@
{
"client": {
"name": "DeepSeek",
"platform": "android",
"version": "2.0.1",
"android_api_level": "35",
"locale": "zh_CN"
},
"base_headers": {
"Host": "chat.deepseek.com",
"User-Agent": "DeepSeek/1.8.0 Android/35",
"Accept": "application/json",
"x-client-platform": "android",
"x-client-version": "1.8.0",
"x-client-locale": "zh_CN",
"Content-Type": "application/json",
"accept-charset": "UTF-8"
},
"skip_contains_patterns": [

View File

@@ -1,11 +1,32 @@
package protocol
import "testing"
import (
"encoding/json"
"testing"
)
func TestSharedConstantsLoaded(t *testing.T) {
cfg := sharedConstants{}
if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil {
t.Fatalf("failed to parse shared constants: %v", err)
}
client := normalizeClientConstants(cfg.Client)
if ClientVersion != client.Version {
t.Fatalf("unexpected client version=%q", ClientVersion)
}
wantUserAgent := client.Name + "/" + client.Version + " Android/" + client.AndroidAPILevel
if BaseHeaders["User-Agent"] != wantUserAgent {
t.Fatalf("unexpected user agent=%q", BaseHeaders["User-Agent"])
}
if BaseHeaders["x-client-platform"] != "android" {
t.Fatalf("unexpected base header x-client-platform=%q", BaseHeaders["x-client-platform"])
}
if BaseHeaders["x-client-version"] != ClientVersion {
t.Fatalf("unexpected base header x-client-version=%q", BaseHeaders["x-client-version"])
}
if BaseHeaders["Content-Type"] != "application/json" {
t.Fatalf("unexpected base header Content-Type=%q", BaseHeaders["Content-Type"])
}
if len(SkipContainsPatterns) == 0 {
t.Fatal("expected skip contains patterns to be loaded")
}
@@ -13,3 +34,23 @@ func TestSharedConstantsLoaded(t *testing.T) {
t.Fatal("expected response/search_status in exact skip path set")
}
}
func TestClientHeadersDerivedFromSharedVersion(t *testing.T) {
client := normalizeClientConstants(clientConstants{
Name: "DeepSeek",
Platform: "android",
Version: "9.8.7",
AndroidAPILevel: "35",
Locale: "zh_CN",
})
headers := buildBaseHeaders(client, map[string]string{
"User-Agent": "stale",
"x-client-version": "stale",
})
if headers["User-Agent"] != "DeepSeek/9.8.7 Android/35" {
t.Fatalf("unexpected derived user agent=%q", headers["User-Agent"])
}
if headers["x-client-version"] != "9.8.7" {
t.Fatalf("unexpected derived client version=%q", headers["x-client-version"])
}
}

View File

@@ -7,15 +7,19 @@ import (
)
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, toolNames)
return BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText, detected.Calls)
}
func BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText string, detected []toolcall.ParsedToolCall) map[string]any {
finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" {
messageObj["reasoning_content"] = finalThinking
}
if len(detected.Calls) > 0 {
if len(detected) > 0 {
finishReason = "tool_calls"
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected.Calls)
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected)
messageObj["content"] = nil
}

View File

@@ -12,12 +12,16 @@ import (
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
// Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls.
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
detected := toolcall.ParseAssistantToolCallsDetailed(finalText, finalThinking, toolNames)
return BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, finalThinking, finalText, detected.Calls)
}
func BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, finalThinking, finalText string, detected []toolcall.ParsedToolCall) map[string]any {
exposedOutputText := finalText
output := make([]any, 0, 2)
if len(detected.Calls) > 0 {
if len(detected) > 0 {
exposedOutputText = ""
output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
output = append(output, toResponsesFunctionCallItems(detected)...)
} else {
content := make([]any, 0, 2)
if finalThinking != "" {

View File

@@ -67,22 +67,22 @@ func TestBuildResponseObjectReasoningOnlyFallsBackToOutputText(t *testing.T) {
}
}
func TestBuildResponseObjectIgnoresToolCallFromThinkingChannel(t *testing.T) {
func TestBuildResponseObjectPromotesToolCallFromThinkingWhenTextEmpty(t *testing.T) {
obj := BuildResponseObject(
"resp_test",
"gpt-4o",
"prompt",
`{"tool_calls":[{"name":"search","input":{"q":"from-thinking"}}]}`,
`<tool_calls><invoke name="search"><parameter name="q">from-thinking</parameter></invoke></tool_calls>`,
"",
[]string{"search"},
)
output, _ := obj["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"])
t.Fatalf("expected one output item, got %#v", obj["output"])
}
first, _ := output[0].(map[string]any)
if first["type"] != "message" {
t.Fatalf("expected output message, got %#v", first["type"])
if first["type"] != "function_call" {
t.Fatalf("expected function_call output, got %#v", first["type"])
}
}

View File

@@ -47,7 +47,7 @@ func TestGetSettingsIncludesTokenRefreshInterval(t *testing.T) {
}
}
func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
func TestGetSettingsIncludesCurrentInputFileDefaults(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
rec := httptest.NewRecorder()
@@ -57,12 +57,22 @@ func TestGetSettingsIncludesHistorySplitDefaults(t *testing.T) {
}
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
historySplit, _ := body["history_split"].(map[string]any)
if got := boolFrom(historySplit["enabled"]); !got {
t.Fatalf("expected history_split.enabled=true, body=%v", body)
currentInputFile, _ := body["current_input_file"].(map[string]any)
if got := boolFrom(currentInputFile["enabled"]); !got {
t.Fatalf("expected current_input_file.enabled=true, body=%v", body)
}
if got := intFrom(historySplit["trigger_after_turns"]); got != 1 {
t.Fatalf("expected history_split.trigger_after_turns=1, got %d body=%v", got, body)
if got := intFrom(currentInputFile["min_chars"]); got != 0 {
t.Fatalf("expected current_input_file.min_chars=0, got %d body=%v", got, body)
}
thinkingInjection, _ := body["thinking_injection"].(map[string]any)
if got := boolFrom(thinkingInjection["enabled"]); !got {
t.Fatalf("expected thinking_injection.enabled=true, body=%v", body)
}
if got, _ := thinkingInjection["prompt"].(string); got != "" {
t.Fatalf("expected empty custom thinking prompt, got %q body=%v", got, body)
}
if got, _ := thinkingInjection["default_prompt"].(string); got == "" {
t.Fatalf("expected default thinking prompt, body=%v", body)
}
}
@@ -173,12 +183,12 @@ func TestUpdateSettingsWithoutRuntimeSkipsMergedRuntimeValidation(t *testing.T)
}
}
func TestUpdateSettingsHistorySplit(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
func TestUpdateSettingsCurrentInputFile(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"history_split":{"enabled":true,"trigger_after_turns":2}}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": false,
"trigger_after_turns": 3,
"current_input_file": map[string]any{
"enabled": true,
"min_chars": 12345,
},
}
b, _ := json.Marshal(payload)
@@ -189,11 +199,161 @@ func TestUpdateSettingsHistorySplit(t *testing.T) {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.HistorySplit.Enabled == nil || !*snap.HistorySplit.Enabled {
t.Fatalf("expected history_split.enabled to be forced true, got %#v", snap.HistorySplit.Enabled)
if snap.CurrentInputFile.Enabled == nil || !*snap.CurrentInputFile.Enabled {
t.Fatalf("expected current_input_file.enabled=true, got %#v", snap.CurrentInputFile)
}
if snap.HistorySplit.TriggerAfterTurns == nil || *snap.HistorySplit.TriggerAfterTurns != 3 {
t.Fatalf("expected history_split.trigger_after_turns=3, got %#v", snap.HistorySplit.TriggerAfterTurns)
if snap.CurrentInputFile.MinChars != 12345 {
t.Fatalf("expected current_input_file.min_chars=12345, got %#v", snap.CurrentInputFile)
}
if !h.Store.CurrentInputFileEnabled() {
t.Fatal("expected current input file accessor to stay enabled")
}
if h.Store.HistorySplitEnabled() {
t.Fatal("expected history split accessor to stay disabled")
}
}
func TestUpdateSettingsCurrentInputFilePartialUpdatePreservesEnabled(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"current_input_file":{"enabled":false,"min_chars":777}}`)
payload := map[string]any{
"current_input_file": map[string]any{
"min_chars": 5000,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.CurrentInputFile.Enabled == nil || *snap.CurrentInputFile.Enabled {
t.Fatalf("expected current_input_file.enabled to remain false, got %#v", snap.CurrentInputFile.Enabled)
}
if snap.CurrentInputFile.MinChars != 5000 {
t.Fatalf("expected current_input_file.min_chars=5000, got %#v", snap.CurrentInputFile)
}
}
func TestUpdateSettingsCurrentInputFilePartialUpdatePreservesMinChars(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"current_input_file":{"enabled":false,"min_chars":777}}`)
payload := map[string]any{
"current_input_file": map[string]any{
"enabled": true,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.CurrentInputFile.Enabled == nil || !*snap.CurrentInputFile.Enabled {
t.Fatalf("expected current_input_file.enabled=true, got %#v", snap.CurrentInputFile.Enabled)
}
if snap.CurrentInputFile.MinChars != 777 {
t.Fatalf("expected current_input_file.min_chars to remain 777, got %#v", snap.CurrentInputFile)
}
}
func TestUpdateSettingsIgnoresHistorySplitPayload(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"history_split": map[string]any{
"enabled": true,
"trigger_after_turns": 3,
},
"current_input_file": map[string]any{
"enabled": true,
"min_chars": 0,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.CurrentInputFile.Enabled == nil || !*snap.CurrentInputFile.Enabled {
t.Fatalf("expected current_input_file to remain enabled, got %#v", snap.CurrentInputFile.Enabled)
}
}
func TestUpdateSettingsThinkingInjection(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"]}`)
payload := map[string]any{
"thinking_injection": map[string]any{
"enabled": false,
"prompt": " custom thinking prompt ",
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.ThinkingInjection.Enabled == nil || *snap.ThinkingInjection.Enabled {
t.Fatalf("expected thinking_injection.enabled=false, got %#v", snap.ThinkingInjection.Enabled)
}
if h.Store.ThinkingInjectionEnabled() {
t.Fatal("expected thinking injection accessor to reflect disabled config")
}
if got := h.Store.ThinkingInjectionPrompt(); got != "custom thinking prompt" {
t.Fatalf("expected custom thinking prompt, got %q", got)
}
}
func TestUpdateSettingsThinkingInjectionPartialPromptPreservesEnabled(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"thinking_injection":{"enabled":false,"prompt":"original prompt"}}`)
payload := map[string]any{
"thinking_injection": map[string]any{
"prompt": " updated prompt ",
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.ThinkingInjection.Enabled == nil || *snap.ThinkingInjection.Enabled {
t.Fatalf("expected thinking_injection.enabled to remain false, got %#v", snap.ThinkingInjection.Enabled)
}
if got := h.Store.ThinkingInjectionPrompt(); got != "updated prompt" {
t.Fatalf("expected updated prompt, got %q", got)
}
}
func TestUpdateSettingsThinkingInjectionPartialEnabledPreservesPrompt(t *testing.T) {
h := newAdminTestHandler(t, `{"keys":["k1"],"thinking_injection":{"enabled":false,"prompt":"original prompt"}}`)
payload := map[string]any{
"thinking_injection": map[string]any{
"enabled": true,
},
}
b, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.updateSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
snap := h.Store.Snapshot()
if snap.ThinkingInjection.Enabled == nil || !*snap.ThinkingInjection.Enabled {
t.Fatalf("expected thinking_injection.enabled=true, got %#v", snap.ThinkingInjection.Enabled)
}
if got := h.Store.ThinkingInjectionPrompt(); got != "original prompt" {
t.Fatalf("expected original prompt to be preserved, got %q", got)
}
}

View File

@@ -16,6 +16,24 @@ func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "chat history store is not configured"})
return
}
ifNoneMatch := strings.TrimSpace(r.Header.Get("If-None-Match"))
if ifNoneMatch != "" {
revision, err := store.Revision()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"detail": err.Error(),
"path": store.Path(),
})
return
}
etag := chathistory.ListETag(revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if ifNoneMatch == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
snapshot, err := store.Snapshot()
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
@@ -27,7 +45,7 @@ func (h *Handler) getChatHistory(w http.ResponseWriter, r *http.Request) {
etag := chathistory.ListETag(snapshot.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
if ifNoneMatch == etag {
w.WriteHeader(http.StatusNotModified)
return
}
@@ -51,6 +69,25 @@ func (h *Handler) getChatHistoryItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "history id is required"})
return
}
ifNoneMatch := strings.TrimSpace(r.Header.Get("If-None-Match"))
if ifNoneMatch != "" {
revision, err := store.DetailRevision(id)
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(strings.ToLower(err.Error()), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]any{"detail": err.Error()})
return
}
etag := chathistory.DetailETag(id, revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if ifNoneMatch == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
item, err := store.Get(id)
if err != nil {
status := http.StatusInternalServerError
@@ -63,7 +100,7 @@ func (h *Handler) getChatHistoryItem(w http.ResponseWriter, r *http.Request) {
etag := chathistory.DetailETag(item.ID, item.Revision)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if strings.TrimSpace(r.Header.Get("If-None-Match")) == etag {
if ifNoneMatch == etag {
w.WriteHeader(http.StatusNotModified)
return
}

View File

@@ -95,6 +95,15 @@ func TestGetChatHistoryAndUpdateSettings(t *testing.T) {
t.Fatalf("expected detail etag header")
}
notModifiedItemReq := httptest.NewRequest(http.MethodGet, "/chat-history/"+entry.ID, nil)
notModifiedItemReq.Header.Set("Authorization", "Bearer admin")
notModifiedItemReq.Header.Set("If-None-Match", itemRec.Header().Get("ETag"))
notModifiedItemRec := httptest.NewRecorder()
r.ServeHTTP(notModifiedItemRec, notModifiedItemReq)
if notModifiedItemRec.Code != http.StatusNotModified {
t.Fatalf("expected detail 304, got %d body=%s", notModifiedItemRec.Code, notModifiedItemRec.Body.String())
}
updateReq := httptest.NewRequest(http.MethodPut, "/chat-history/settings", bytes.NewReader([]byte(`{"limit":10}`)))
updateReq.Header.Set("Authorization", "Bearer admin")
updateRec := httptest.NewRecorder()

View File

@@ -21,7 +21,7 @@ func boolFrom(v any) bool {
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.HistorySplitConfig, map[string]string, error) {
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.CompatConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, error) {
var (
adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig
@@ -29,7 +29,8 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig
autoDeleteCfg *config.AutoDeleteConfig
historySplitCfg *config.HistorySplitConfig
currentInputCfg *config.CurrentInputFileConfig
thinkingInjCfg *config.ThinkingInjectionConfig
aliasMap map[string]string
)
@@ -38,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.JWTExpireHours = n
}
@@ -50,33 +51,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxInflight = n
}
if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.AccountMaxQueue = n
}
if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.GlobalMaxInflight = n
}
if v, exists := raw["token_refresh_interval_hours"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TokenRefreshIntervalHours = n
}
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
}
runtimeCfg = cfg
}
@@ -99,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.StoreTTLSeconds = n
}
@@ -111,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v))
if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.Provider = p
}
@@ -137,7 +138,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["mode"]; exists {
mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
if err := config.ValidateAutoDeleteMode(mode); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
if mode == "" {
mode = "none"
@@ -150,22 +151,36 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
autoDeleteCfg = cfg
}
if raw, ok := req["history_split"].(map[string]any); ok {
cfg := &config.HistorySplitConfig{}
enabled := true
cfg.Enabled = &enabled
if v, exists := raw["trigger_after_turns"]; exists {
if raw, ok := req["current_input_file"].(map[string]any); ok {
cfg := &config.CurrentInputFileConfig{}
if v, exists := raw["enabled"]; exists {
enabled := boolFrom(v)
cfg.Enabled = &enabled
}
if v, exists := raw["min_chars"]; exists {
n := intFrom(v)
if err := config.ValidateIntRange("history_split.trigger_after_turns", n, 1, 1000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
cfg.TriggerAfterTurns = &n
cfg.MinChars = n
}
if err := config.ValidateHistorySplitConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
historySplitCfg = cfg
currentInputCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, historySplitCfg, aliasMap, nil
if raw, ok := req["thinking_injection"].(map[string]any); ok {
cfg := &config.ThinkingInjectionConfig{}
if v, exists := raw["enabled"]; exists {
b := boolFrom(v)
cfg.Enabled = &b
}
if v, exists := raw["prompt"]; exists {
cfg.Prompt = strings.TrimSpace(fmt.Sprintf("%v", v))
}
thinkingInjCfg = cfg
}
return adminCfg, runtimeCfg, compatCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, nil
}

View File

@@ -6,6 +6,7 @@ import (
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/promptcompat"
)
func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
@@ -30,9 +31,14 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"responses": snap.Responses,
"embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"history_split": map[string]any{
"enabled": h.Store.HistorySplitEnabled(),
"trigger_after_turns": h.Store.HistorySplitTriggerAfterTurns(),
"current_input_file": map[string]any{
"enabled": h.Store.CurrentInputFileEnabled(),
"min_chars": h.Store.CurrentInputFileMinChars(),
},
"thinking_injection": map[string]any{
"enabled": h.Store.ThinkingInjectionEnabled(),
"prompt": h.Store.ThinkingInjectionPrompt(),
"default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
},
"model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(),

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, historySplitCfg, aliasMap, err := parseSettingsUpdateRequest(req)
adminCfg, runtimeCfg, compatCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
@@ -28,6 +28,10 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
currentInputEnabledSet := hasNestedSettingsKey(req, "current_input_file", "enabled")
currentInputMinCharsSet := hasNestedSettingsKey(req, "current_input_file", "min_chars")
thinkingInjectionEnabledSet := hasNestedSettingsKey(req, "thinking_injection", "enabled")
thinkingInjectionPromptSet := hasNestedSettingsKey(req, "thinking_injection", "prompt")
if err := h.Store.Update(func(c *config.Config) error {
if adminCfg != nil {
@@ -67,12 +71,20 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
c.AutoDelete.Mode = autoDeleteCfg.Mode
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if historySplitCfg != nil {
if historySplitCfg.Enabled != nil {
c.HistorySplit.Enabled = historySplitCfg.Enabled
if currentInputCfg != nil {
if currentInputEnabledSet {
c.CurrentInputFile.Enabled = currentInputCfg.Enabled
}
if historySplitCfg.TriggerAfterTurns != nil {
c.HistorySplit.TriggerAfterTurns = historySplitCfg.TriggerAfterTurns
if currentInputMinCharsSet {
c.CurrentInputFile.MinChars = currentInputCfg.MinChars
}
}
if thinkingInjCfg != nil {
if thinkingInjectionEnabledSet {
c.ThinkingInjection.Enabled = thinkingInjCfg.Enabled
}
if thinkingInjectionPromptSet {
c.ThinkingInjection.Prompt = thinkingInjCfg.Prompt
}
}
if aliasMap != nil {
@@ -128,3 +140,12 @@ func (h *Handler) updateSettingsPassword(w http.ResponseWriter, r *http.Request)
"jwt_valid_after_unix": now,
})
}
func hasNestedSettingsKey(req map[string]any, section, key string) bool {
raw, ok := req[section].(map[string]any)
if !ok {
return false
}
_, exists := raw[key]
return exists
}

View File

@@ -35,6 +35,10 @@ type ConfigStore interface {
AutoDeleteMode() string
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CurrentInputFileEnabled() bool
CurrentInputFileMinChars() int
ThinkingInjectionEnabled() bool
ThinkingInjectionPrompt() string
CompatStripReferenceMarkers() bool
AutoDeleteSessions() bool
}

View File

@@ -53,6 +53,26 @@ func TestNormalizeClaudeRequestEnablesThinkingWhenRequested(t *testing.T) {
}
}
func TestNormalizeClaudeRequestNoThinkingAliasForcesThinkingOff(t *testing.T) {
req := map[string]any{
"model": "claude-opus-4-6-nothinking",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
"thinking": map[string]any{"type": "enabled", "budget_tokens": 1024},
}
out, err := normalizeClaudeRequest(mockClaudeConfig{}, req)
if err != nil {
t.Fatalf("normalizeClaudeRequest error: %v", err)
}
if out.Standard.ResolvedModel != "deepseek-v4-pro-nothinking" {
t.Fatalf("resolved model mismatch: got=%q", out.Standard.ResolvedModel)
}
if out.Standard.Thinking {
t.Fatalf("expected nothinking alias to force downstream thinking off")
}
}
func TestNormalizeClaudeRequestPrefersGlobalAliasMapping(t *testing.T) {
req := map[string]any{
"model": "claude-sonnet-4-6",

View File

@@ -52,7 +52,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
}
}
translatedReq := translatorcliproxy.ToOpenAI(sdktranslator.FormatClaude, translateModel, raw, stream)
translatedReq = applyClaudeThinkingPolicyToOpenAIRequest(translatedReq, req)
translatedReq, exposeThinking := applyClaudeThinkingPolicyToOpenAIRequest(translatedReq, req, stream)
isVercelPrepare := strings.TrimSpace(r.URL.Query().Get("__stream_prepare")) == "1"
isVercelRelease := strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"
@@ -118,23 +118,26 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
return true
}
converted := translatorcliproxy.FromOpenAINonStream(sdktranslator.FormatClaude, model, raw, translatedReq, body)
if !exposeThinking {
converted = stripClaudeThinkingBlocks(converted)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(converted)
return true
}
func applyClaudeThinkingPolicyToOpenAIRequest(translated []byte, original map[string]any) []byte {
func applyClaudeThinkingPolicyToOpenAIRequest(translated []byte, original map[string]any, stream bool) ([]byte, bool) {
req := map[string]any{}
if err := json.Unmarshal(translated, &req); err != nil {
return translated
return translated, false
}
enabled, ok := util.ResolveThinkingOverride(original)
if !ok {
if _, translatedHasOverride := util.ResolveThinkingOverride(req); translatedHasOverride {
return translated
return translated, false
}
enabled = false
enabled = !stream
}
typ := "disabled"
if enabled {
@@ -143,7 +146,33 @@ func applyClaudeThinkingPolicyToOpenAIRequest(translated []byte, original map[st
req["thinking"] = map[string]any{"type": typ}
out, err := json.Marshal(req)
if err != nil {
return translated
return translated, ok && enabled
}
return out, ok && enabled
}
func stripClaudeThinkingBlocks(raw []byte) []byte {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return raw
}
content, _ := payload["content"].([]any)
if len(content) == 0 {
return raw
}
filtered := make([]any, 0, len(content))
for _, item := range content {
block, _ := item.(map[string]any)
blockType, _ := block["type"].(string)
if strings.TrimSpace(blockType) == "thinking" {
continue
}
filtered = append(filtered, item)
}
payload["content"] = filtered
out, err := json.Marshal(payload)
if err != nil {
return raw
}
return out
}

View File

@@ -93,10 +93,10 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) {
t.Fatalf("expected call id preserved, got %#v", call)
}
content, _ := m["content"].(string)
if !containsStr(content, "<tool_calls>") || !containsStr(content, `<invoke name="search_web">`) {
t.Fatalf("expected assistant content to include XML tool call history, got %q", content)
if !containsStr(content, "<|DSML|tool_calls>") || !containsStr(content, `<|DSML|invoke name="search_web">`) {
t.Fatalf("expected assistant content to include DSML tool call history, got %q", content)
}
if !containsStr(content, `<parameter name="query"><![CDATA[latest]]></parameter>`) {
if !containsStr(content, `<|DSML|parameter name="query"><![CDATA[latest]]></|DSML|parameter>`) {
t.Fatalf("expected assistant content to include serialized parameters, got %q", content)
}
}
@@ -292,8 +292,8 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt")
}
if !containsStr(prompt, "<tool_calls>") {
t.Fatalf("expected XML tool_calls format in prompt")
if !containsStr(prompt, "<|DSML|tool_calls>") {
t.Fatalf("expected DSML tool_calls format in prompt")
}
if !containsStr(prompt, "TOOL CALL FORMAT") {
t.Fatalf("expected tool call format header in prompt")

View File

@@ -126,7 +126,7 @@ func TestClaudeProxyViaOpenAIPreservesThinkingOverride(t *testing.T) {
}
}
func TestClaudeProxyViaOpenAIDisablesThinkingByDefault(t *testing.T) {
func TestClaudeProxyViaOpenAIEnablesThinkingInternallyByDefaultForNonStream(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{
Store: claudeProxyStoreStub{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}},
@@ -141,8 +141,8 @@ func TestClaudeProxyViaOpenAIDisablesThinkingByDefault(t *testing.T) {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
thinking, _ := openAI.seenReq["thinking"].(map[string]any)
if thinking["type"] != "disabled" {
t.Fatalf("expected Claude default to disable downstream thinking, got %#v", openAI.seenReq)
if thinking["type"] != "enabled" {
t.Fatalf("expected Claude non-stream default to enable downstream thinking internally, got %#v", openAI.seenReq)
}
}
@@ -166,6 +166,43 @@ func TestClaudeProxyViaOpenAIEnablesThinkingWhenRequested(t *testing.T) {
}
}
func TestClaudeProxyViaOpenAIKeepsStreamDefaultThinkingDisabled(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{
Store: claudeProxyStoreStub{aliases: map[string]string{"claude-sonnet-4-6": "deepseek-v4-flash"}},
OpenAI: openAI,
}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}],"stream":true}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
thinking, _ := openAI.seenReq["thinking"].(map[string]any)
if thinking["type"] != "disabled" {
t.Fatalf("expected Claude stream default to keep downstream thinking disabled, got %#v", openAI.seenReq)
}
}
func TestClaudeProxyViaOpenAIStripsThinkingBlocksFromNonStreamResponse(t *testing.T) {
body := `{"id":"chatcmpl_1","object":"chat.completion","created":1,"model":"claude-sonnet-4-5","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":"internal reasoning","tool_calls":[{"id":"call_1","type":"function","function":{"name":"search","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`
h := &Handler{OpenAI: openAIProxyStub{status: 200, body: body}}
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"stream":false}`))
rec := httptest.NewRecorder()
h.Messages(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String())
}
got := rec.Body.String()
if strings.Contains(got, `"type":"thinking"`) {
t.Fatalf("expected converted Claude response to strip thinking block, got %s", got)
}
if !strings.Contains(got, `"tool_use"`) {
t.Fatalf("expected converted Claude response to preserve tool_use, got %s", got)
}
}
func TestClaudeProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) {
openAI := &openAIProxyCaptureStub{}
h := &Handler{OpenAI: openAI}

View File

@@ -37,6 +37,9 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
searchEnabled = false
}
thinkingEnabled := util.ResolveThinkingEnabled(req, false)
if config.IsNoThinkingModel(dsModel) {
thinkingEnabled = false
}
finalPrompt := prompt.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled)
toolNames := extractClaudeToolNames(toolsRequested)
if len(toolNames) == 0 && len(toolsRequested) > 0 {

View File

@@ -22,6 +22,9 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
}
defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled)
if config.IsNoThinkingModel(resolvedModel) {
thinkingEnabled = false
}
messagesRaw := geminiMessagesFromRequest(req)
if len(messagesRaw) == 0 {

View File

@@ -0,0 +1,28 @@
package gemini
import "testing"
func TestNormalizeGeminiRequestNoThinkingModelForcesThinkingOff(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "user",
"parts": []any{map[string]any{"text": "hello"}},
},
},
"reasoning_effort": "high",
}
out, err := normalizeGeminiRequest(testGeminiConfig{}, "gemini-2.5-pro-nothinking", req, false)
if err != nil {
t.Fatalf("normalizeGeminiRequest error: %v", err)
}
if out.ResolvedModel != "deepseek-v4-pro-nothinking" {
t.Fatalf("resolved model mismatch: got=%q", out.ResolvedModel)
}
if out.Thinking {
t.Fatalf("expected nothinking model to force thinking off")
}
if out.Search {
t.Fatalf("expected search=false, got=%v", out.Search)
}
}

View File

@@ -272,14 +272,13 @@ func TestChatCompletionsSkipsHistoryWhenDisabled(t *testing.T) {
}
}
func TestChatCompletionsHistorySplitPersistsHistoryText(t *testing.T) {
func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
historyStore := newTestChatHistoryStore(t)
ds := &inlineUploadDSStub{}
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -308,19 +307,19 @@ func TestChatCompletionsHistorySplitPersistsHistoryText(t *testing.T) {
if err != nil {
t.Fatalf("expected detail item, got %v", err)
}
if full.HistoryText == "" {
t.Fatalf("expected history text to be persisted")
}
if !strings.Contains(full.HistoryText, "first user turn") || !strings.Contains(full.HistoryText, "tool result") {
t.Fatalf("expected earlier turns in history text, got %q", full.HistoryText)
}
if strings.Contains(full.HistoryText, "latest user turn") {
t.Fatalf("expected latest turn to stay out of persisted history text, got %q", full.HistoryText)
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 history upload to happen, got %d", len(ds.uploadCalls))
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
}
if full.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected persisted history text to match uploaded HISTORY.txt contents")
if ds.uploadCalls[0].Filename != "IGNORE.txt" {
t.Fatalf("expected IGNORE.txt upload, got %q", ds.uploadCalls[0].Filename)
}
if len(full.Messages) != 1 {
t.Fatalf("expected compacted-context prompt to be the only persisted message, got %#v", full.Messages)
}
if !strings.Contains(full.Messages[0].Content, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context prompt to be persisted, got %#v", full.Messages[0])
}
}

View File

@@ -1,7 +1,6 @@
package chat
import (
"ds2api/internal/toolcall"
"encoding/json"
"net/http"
"strings"
@@ -33,11 +32,13 @@ type chatStreamRuntime struct {
toolCallsEmitted bool
toolCallsDoneEmitted bool
toolSieve toolstream.State
streamToolCallIDs map[int]string
streamToolNames map[int]string
thinking strings.Builder
text strings.Builder
toolSieve toolstream.State
streamToolCallIDs map[int]string
streamToolNames map[int]string
thinking strings.Builder
toolDetectionThinking strings.Builder
text strings.Builder
responseMessageID int
finalThinking string
finalText string
@@ -128,12 +129,16 @@ func (s *chatStreamRuntime) resetStreamToolCallState() {
s.streamToolNames = map[int]string{}
}
func (s *chatStreamRuntime) finalize(finishReason string) {
func (s *chatStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) bool {
s.finalErrorStatus = 0
s.finalErrorMessage = ""
s.finalErrorCode = ""
finalThinking := s.thinking.String()
finalToolDetectionThinking := s.toolDetectionThinking.String()
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
s.finalThinking = finalThinking
s.finalText = finalText
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls"
delta := map[string]any{
@@ -203,8 +208,14 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
}
if len(detected.Calls) == 0 && !s.toolCallsEmitted && strings.TrimSpace(finalText) == "" {
status, message, code := upstreamEmptyOutputDetail(finishReason == "content_filter", finalText, finalThinking)
if deferEmptyOutput {
s.finalErrorStatus = status
s.finalErrorMessage = message
s.finalErrorCode = code
return false
}
s.sendFailedChunk(status, message, code)
return
return true
}
usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText)
s.finalFinishReason = finishReason
@@ -217,12 +228,16 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
usage,
))
s.sendDone()
return true
}
func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.ResponseMessageID > 0 {
s.responseMessageID = parsed.ResponseMessageID
}
if parsed.ContentFilter {
if strings.TrimSpace(s.text.String()) == "" {
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")}
@@ -238,6 +253,12 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
newChoices := make([]map[string]any, 0, len(parsed.Parts))
contentSeen := false
for _, p := range parsed.ToolDetectionThinkingParts {
trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text)
if trimmed != "" {
s.toolDetectionThinking.WriteString(trimmed)
}
}
for _, p := range parsed.Parts {
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
if s.searchEnabled && sse.IsCitation(cleanedText) {

View File

@@ -0,0 +1,283 @@
package chat
import (
"context"
"io"
"net/http"
"strings"
"time"
"ds2api/internal/auth"
"ds2api/internal/config"
dsprotocol "ds2api/internal/deepseek/protocol"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
)
type chatNonStreamResult struct {
thinking string
toolDetectionThinking string
text string
contentFilter bool
detectedCalls int
body map[string]any
finishReason string
responseMessageID int
}
func (h *Handler) handleNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) {
attempts := 0
currentResp := resp
usagePrompt := finalPrompt
accumulatedThinking := ""
accumulatedToolDetectionThinking := ""
for {
result, ok := h.collectChatNonStreamAttempt(w, currentResp, completionID, model, usagePrompt, thinkingEnabled, searchEnabled, toolNames)
if !ok {
return
}
accumulatedThinking += sse.TrimContinuationOverlap(accumulatedThinking, result.thinking)
accumulatedToolDetectionThinking += sse.TrimContinuationOverlap(accumulatedToolDetectionThinking, result.toolDetectionThinking)
result.thinking = accumulatedThinking
result.toolDetectionThinking = accumulatedToolDetectionThinking
detected := detectAssistantToolCalls(result.text, result.thinking, result.toolDetectionThinking, toolNames)
result.detectedCalls = len(detected.Calls)
result.body = openaifmt.BuildChatCompletionWithToolCalls(completionID, model, usagePrompt, result.thinking, result.text, detected.Calls)
result.finishReason = chatFinishReason(result.body)
if !shouldRetryChatNonStream(result, attempts) {
h.finishChatNonStreamResult(w, result, attempts, usagePrompt, historySession)
return
}
attempts++
config.Logger.Info("[openai_empty_retry] attempting synthetic retry", "surface", "chat.completions", "stream", false, "retry_attempt", attempts, "parent_message_id", result.responseMessageID)
retryPow, powErr := h.DS.GetPow(ctx, a, 3)
if powErr != nil {
config.Logger.Warn("[openai_empty_retry] retry PoW fetch failed, falling back to original PoW", "surface", "chat.completions", "stream", false, "retry_attempt", attempts, "error", powErr)
retryPow = pow
}
retryPayload := clonePayloadForEmptyOutputRetry(payload, result.responseMessageID)
nextResp, err := h.DS.CallCompletion(ctx, a, retryPayload, retryPow, 3)
if err != nil {
if historySession != nil {
historySession.error(http.StatusInternalServerError, "Failed to get completion.", "error", result.thinking, result.text)
}
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "chat.completions", "stream", false, "retry_attempt", attempts, "error", err)
return
}
usagePrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts)
currentResp = nextResp
}
}
func (h *Handler) collectChatNonStreamAttempt(w http.ResponseWriter, resp *http.Response, completionID, model, usagePrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) (chatNonStreamResult, bool) {
if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, string(body))
return chatNonStreamResult{}, false
}
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)
respBody := openaifmt.BuildChatCompletionWithToolCalls(completionID, model, usagePrompt, finalThinking, finalText, detected.Calls)
return chatNonStreamResult{
thinking: finalThinking,
toolDetectionThinking: finalToolDetectionThinking,
text: finalText,
contentFilter: result.ContentFilter,
detectedCalls: len(detected.Calls),
body: respBody,
finishReason: chatFinishReason(respBody),
responseMessageID: result.ResponseMessageID,
}, true
}
func (h *Handler) finishChatNonStreamResult(w http.ResponseWriter, result chatNonStreamResult, attempts int, usagePrompt string, historySession *chatHistorySession) {
if result.detectedCalls == 0 && shouldWriteUpstreamEmptyOutputError(result.text) {
status, message, code := upstreamEmptyOutputDetail(result.contentFilter, result.text, result.thinking)
if historySession != nil {
historySession.error(status, message, code, result.thinking, result.text)
}
writeUpstreamEmptyOutputError(w, result.text, result.thinking, result.contentFilter)
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "chat.completions", "stream", false, "retry_attempts", attempts, "success_source", "none", "content_filter", result.contentFilter)
return
}
if historySession != nil {
historySession.success(http.StatusOK, result.thinking, result.text, result.finishReason, openaifmt.BuildChatUsage(usagePrompt, result.thinking, result.text))
}
writeJSON(w, http.StatusOK, result.body)
source := "first_attempt"
if attempts > 0 {
source = "synthetic_retry"
}
config.Logger.Info("[openai_empty_retry] completed", "surface", "chat.completions", "stream", false, "retry_attempts", attempts, "success_source", source)
}
func chatFinishReason(respBody map[string]any) string {
if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 {
if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" {
return fr
}
}
return "stop"
}
func shouldRetryChatNonStream(result chatNonStreamResult, attempts int) bool {
return emptyOutputRetryEnabled() &&
attempts < emptyOutputRetryMaxAttempts() &&
!result.contentFilter &&
result.detectedCalls == 0 &&
strings.TrimSpace(result.text) == ""
}
func (h *Handler) handleStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) {
streamRuntime, initialType, ok := h.prepareChatStreamRuntime(w, resp, completionID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames, historySession)
if !ok {
return
}
attempts := 0
currentResp := resp
for {
terminalWritten, retryable := h.consumeChatStreamAttempt(r, currentResp, streamRuntime, initialType, thinkingEnabled, historySession, attempts < emptyOutputRetryMaxAttempts())
if terminalWritten {
logChatStreamTerminal(streamRuntime, attempts)
return
}
if !retryable || !emptyOutputRetryEnabled() || attempts >= emptyOutputRetryMaxAttempts() {
streamRuntime.finalize("stop", false)
recordChatStreamHistory(streamRuntime, historySession)
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "success_source", "none")
return
}
attempts++
config.Logger.Info("[openai_empty_retry] attempting synthetic retry", "surface", "chat.completions", "stream", true, "retry_attempt", attempts, "parent_message_id", streamRuntime.responseMessageID)
retryPow, powErr := h.DS.GetPow(r.Context(), a, 3)
if powErr != nil {
config.Logger.Warn("[openai_empty_retry] retry PoW fetch failed, falling back to original PoW", "surface", "chat.completions", "stream", true, "retry_attempt", attempts, "error", powErr)
retryPow = pow
}
nextResp, err := h.DS.CallCompletion(r.Context(), a, clonePayloadForEmptyOutputRetry(payload, streamRuntime.responseMessageID), retryPow, 3)
if err != nil {
failChatStreamRetry(streamRuntime, historySession, http.StatusInternalServerError, "Failed to get completion.", "error")
config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "chat.completions", "stream", true, "retry_attempt", attempts, "error", err)
return
}
if nextResp.StatusCode != http.StatusOK {
defer func() { _ = nextResp.Body.Close() }()
body, _ := io.ReadAll(nextResp.Body)
failChatStreamRetry(streamRuntime, historySession, nextResp.StatusCode, string(body), "error")
return
}
streamRuntime.finalPrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts)
currentResp = nextResp
}
}
func (h *Handler) prepareChatStreamRuntime(w http.ResponseWriter, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, historySession *chatHistorySession) (*chatStreamRuntime, string, bool) {
if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if historySession != nil {
historySession.error(resp.StatusCode, string(body), "error", "", "")
}
writeOpenAIError(w, resp.StatusCode, string(body))
return nil, "", false
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
if !canFlush {
config.Logger.Warn("[stream] response writer does not support flush; streaming may be buffered")
}
initialType := "text"
if thinkingEnabled {
initialType = "thinking"
}
streamRuntime := newChatStreamRuntime(
w, rc, canFlush, completionID, time.Now().Unix(), model, finalPrompt,
thinkingEnabled, searchEnabled, h.compatStripReferenceMarkers(), toolNames,
len(toolNames) > 0, h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence(),
)
return streamRuntime, initialType, true
}
func (h *Handler) consumeChatStreamAttempt(r *http.Request, resp *http.Response, streamRuntime *chatStreamRuntime, initialType string, thinkingEnabled bool, historySession *chatHistorySession, allowDeferEmpty bool) (bool, bool) {
defer func() { _ = resp.Body.Close() }()
finalReason := "stop"
streamengine.ConsumeSSE(streamengine.ConsumeConfig{
Context: r.Context(),
Body: resp.Body,
ThinkingEnabled: thinkingEnabled,
InitialType: initialType,
KeepAliveInterval: time.Duration(dsprotocol.KeepAliveTimeout) * time.Second,
IdleTimeout: time.Duration(dsprotocol.StreamIdleTimeout) * time.Second,
MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount,
}, streamengine.ConsumeHooks{
OnKeepAlive: streamRuntime.sendKeepAlive,
OnParsed: func(parsed sse.LineResult) streamengine.ParsedDecision {
decision := streamRuntime.onParsed(parsed)
if historySession != nil {
historySession.progress(streamRuntime.thinking.String(), streamRuntime.text.String())
}
return decision
},
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
finalReason = "content_filter"
}
},
OnContextDone: func() {
if historySession != nil {
historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled))
}
},
})
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
if terminalWritten {
recordChatStreamHistory(streamRuntime, historySession)
return true, false
}
return false, true
}
func recordChatStreamHistory(streamRuntime *chatStreamRuntime, historySession *chatHistorySession) {
if historySession == nil {
return
}
if streamRuntime.finalErrorMessage != "" {
historySession.error(streamRuntime.finalErrorStatus, streamRuntime.finalErrorMessage, streamRuntime.finalErrorCode, streamRuntime.thinking.String(), streamRuntime.text.String())
return
}
historySession.success(http.StatusOK, streamRuntime.finalThinking, streamRuntime.finalText, streamRuntime.finalFinishReason, streamRuntime.finalUsage)
}
func failChatStreamRetry(streamRuntime *chatStreamRuntime, historySession *chatHistorySession, status int, message, code string) {
streamRuntime.sendFailedChunk(status, message, code)
if historySession != nil {
historySession.error(status, message, code, streamRuntime.thinking.String(), streamRuntime.text.String())
}
}
func logChatStreamTerminal(streamRuntime *chatStreamRuntime, attempts int) {
source := "first_attempt"
if attempts > 0 {
source = "synthetic_retry"
}
if streamRuntime.finalErrorMessage != "" {
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
return
}
config.Logger.Info("[openai_empty_retry] completed", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "success_source", source)
}

View File

@@ -42,11 +42,17 @@ func (h *Handler) compatStripReferenceMarkers() bool {
return shared.CompatStripReferenceMarkers(h.Store)
}
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
func (h *Handler) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if h == nil {
return stdReq, nil
}
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil || out.CurrentInputFileApplied {
return out, err
}
return out, nil
}
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
@@ -82,7 +88,7 @@ func writeOpenAIInlineFileError(w http.ResponseWriter, err error) {
files.WriteInlineFileError(w, err)
}
func mapHistorySplitError(err error) (int, string) {
func mapCurrentInputFileError(err error) (int, string) {
return history.MapError(err)
}
@@ -114,6 +120,22 @@ func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string,
return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter)
}
func emptyOutputRetryEnabled() bool {
return shared.EmptyOutputRetryEnabled()
}
func emptyOutputRetryMaxAttempts() int {
return shared.EmptyOutputRetryMaxAttempts()
}
func clonePayloadForEmptyOutputRetry(payload map[string]any, parentMessageID int) map[string]any {
return shared.ClonePayloadForEmptyOutputRetry(payload, parentMessageID)
}
func usagePromptWithEmptyOutputRetry(originalPrompt string, retryAttempts int) string {
return shared.UsagePromptWithEmptyOutputRetry(originalPrompt, retryAttempts)
}
func formatIncrementalStreamToolCallDeltas(deltas []toolstream.ToolCallDelta, ids map[int]string) []map[string]any {
return shared.FormatIncrementalStreamToolCallDeltas(deltas, ids)
}
@@ -125,3 +147,7 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta,
func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any {
return shared.FormatFinalStreamToolCallsWithStableIDs(calls, ids)
}
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
}

View File

@@ -22,6 +22,10 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
h.handleVercelStreamRelease(w, r)
return
}
if isVercelStreamPowRequest(r) {
h.handleVercelStreamPow(w, r)
return
}
if isVercelStreamPrepareRequest(r) {
h.handleVercelStreamPrepare(w, r)
return
@@ -64,9 +68,9 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
if err != nil {
status, message := mapHistorySplitError(err)
status, message := mapCurrentInputFileError(err)
writeOpenAIError(w, status, message)
return
}
@@ -105,10 +109,10 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
if stdReq.Stream {
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
h.handleStreamWithRetry(w, r, a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
return
}
h.handleNonStream(w, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
h.handleNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, historySession)
}
func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) {
@@ -158,11 +162,13 @@ 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)
}
if shouldWriteUpstreamEmptyOutputError(finalText) {
detected := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, toolNames)
if shouldWriteUpstreamEmptyOutputError(finalText) && len(detected.Calls) == 0 {
status, message, code := upstreamEmptyOutputDetail(result.ContentFilter, finalText, finalThinking)
if historySession != nil {
historySession.error(status, message, code, finalThinking, finalText)
@@ -170,7 +176,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, resp *http.Response, co
writeUpstreamEmptyOutputError(w, finalText, finalThinking, result.ContentFilter)
return
}
respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames)
respBody := openaifmt.BuildChatCompletionWithToolCalls(completionID, model, finalPrompt, finalThinking, finalText, detected.Calls)
finishReason := "stop"
if choices, ok := respBody["choices"].([]map[string]any); ok && len(choices) > 0 {
if fr, _ := choices[0]["finish_reason"].(string); strings.TrimSpace(fr) != "" {
@@ -249,9 +255,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt
},
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
streamRuntime.finalize("content_filter")
streamRuntime.finalize("content_filter", false)
} else {
streamRuntime.finalize("stop")
streamRuntime.finalize("stop", false)
}
if historySession == nil {
return

View File

@@ -142,6 +142,65 @@ func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) {
}
}
func TestHandleNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<tool_calls><invoke name=\"search\"><parameter name=\"q\">from-thinking</parameter></invoke></tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, resp, "cid-thinking-tool", "deepseek-v4-pro", "prompt", true, false, []string{"search"}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
if len(choices) == 0 {
t.Fatalf("expected choices, got %#v", out)
}
choice, _ := choices[0].(map[string]any)
if got := asString(choice["finish_reason"]); got != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
message, _ := choice["message"].(map[string]any)
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one tool call, got %#v", message["tool_calls"])
}
if content, exists := message["content"]; !exists || content != nil {
t.Fatalf("expected content nil when tool call promoted, got %#v", message["content"])
}
}
func TestHandleNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"search\"><|DSML|parameter name=\"q\">from-hidden-thinking</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
h.handleNonStream(rec, resp, "cid-hidden-thinking-tool", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if _, ok := message["reasoning_content"]; ok {
t.Fatalf("expected hidden thinking not to be exposed, got %#v", message)
}
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 1 {
t.Fatalf("expected one hidden-thinking tool call, got %#v", message["tool_calls"])
}
if got := asString(choice["finish_reason"]); got != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
}
}
func TestHandleStreamToolsPlainTextStreamsBeforeFinish(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
@@ -214,6 +273,76 @@ func TestHandleStreamIncompleteCapturedToolJSONFlushesAsTextOnFinalize(t *testin
}
}
func TestHandleStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstreamIntercept(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<tool_calls><invoke name=\"search\"><parameter name=\"q\">from-thinking</parameter></invoke></tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid-thinking-stream", "deepseek-v4-pro", "prompt", true, false, []string{"search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
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
}
}
}
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())
}
}
func TestHandleStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"search\"><|DSML|parameter name=\"q\">from-hidden-thinking</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
h.handleStream(rec, req, resp, "cid-hidden-thinking-stream", "deepseek-v4-pro", "prompt", false, false, []string{"search"}, nil)
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
if !streamHasToolCallsDelta(frames) {
t.Fatalf("expected tool_calls delta from hidden thinking fallback, body=%s", rec.Body.String())
}
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"]) != "" {
t.Fatalf("did not expect hidden reasoning_content delta, body=%s", rec.Body.String())
}
}
}
if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
}
}
func TestHandleStreamEmitsDistinctToolCallIDsAcrossSeparateToolBlocks(t *testing.T) {
h := &Handler{}
resp := makeSSEHTTPResponse(

View File

@@ -20,6 +20,10 @@ type mockOpenAIConfig struct {
embedProv string
historySplitEnabled bool
historySplitTurns int
currentInputEnabled bool
currentInputMin int
thinkingInjection *bool
thinkingPrompt string
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -45,6 +49,17 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
}
return m.historySplitTurns
}
func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled }
func (m mockOpenAIConfig) CurrentInputFileMinChars() int {
return m.currentInputMin
}
func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
if m.thinkingInjection == nil {
return false
}
return *m.thinkingInjection
}
func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt }
type streamStatusAuthStub struct{}

View File

@@ -10,6 +10,7 @@ import (
"ds2api/internal/auth"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/promptcompat"
)
func TestIsVercelStreamPrepareRequest(t *testing.T) {
@@ -87,7 +88,7 @@ func TestStreamLeaseTTL(t *testing.T) {
}
}
func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
func TestHandleVercelStreamPrepareAppliesCurrentInputFile(t *testing.T) {
t.Setenv("VERCEL", "1")
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
@@ -95,8 +96,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -119,7 +119,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 history upload, got %d", len(ds.uploadCalls))
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
}
var body map[string]any
@@ -131,11 +131,11 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
t.Fatalf("expected payload object, got %#v", body["payload"])
}
promptText, _ := payload["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest user turn in prompt, got %s", promptText)
if !strings.Contains(promptText, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context prompt, got %s", promptText)
}
if strings.Contains(promptText, "first user turn") {
t.Fatalf("expected historical turns removed from prompt, got %s", promptText)
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected original turns hidden from prompt, got %s", promptText)
}
refIDs, _ := payload["ref_file_ids"].([]any)
if len(refIDs) == 0 || refIDs[0] != "file-inline-1" {
@@ -143,7 +143,7 @@ func TestHandleVercelStreamPrepareAppliesHistorySplit(t *testing.T) {
}
}
func TestHandleVercelStreamPrepareMapsHistorySplitManagedAuthFailureTo401(t *testing.T) {
func TestHandleVercelStreamPrepareMapsCurrentInputFileManagedAuthFailureTo401(t *testing.T) {
t.Setenv("VERCEL", "1")
t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret")
@@ -153,8 +153,7 @@ func TestHandleVercelStreamPrepareMapsHistorySplitManagedAuthFailureTo401(t *tes
h := &Handler{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusManagedAuthStub{},
DS: ds,

View File

@@ -69,9 +69,9 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque
writeOpenAIError(w, http.StatusBadRequest, "stream must be true")
return
}
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
if err != nil {
status, message := mapHistorySplitError(err)
status, message := mapCurrentInputFileError(err)
writeOpenAIError(w, status, message)
return
}
@@ -150,6 +150,44 @@ func (h *Handler) handleVercelStreamRelease(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, map[string]any{"success": true})
}
func (h *Handler) handleVercelStreamPow(w http.ResponseWriter, r *http.Request) {
if !config.IsVercel() {
http.NotFound(w, r)
return
}
internalSecret := vercelInternalSecret()
internalToken := strings.TrimSpace(r.Header.Get("X-Ds2-Internal-Token"))
if internalSecret == "" || subtle.ConstantTimeCompare([]byte(internalToken), []byte(internalSecret)) != 1 {
writeOpenAIError(w, http.StatusUnauthorized, "unauthorized internal request")
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid json")
return
}
leaseID, _ := req["lease_id"].(string)
leaseID = strings.TrimSpace(leaseID)
if leaseID == "" {
writeOpenAIError(w, http.StatusBadRequest, "lease_id is required")
return
}
leaseAuth := h.lookupStreamLeaseAuth(leaseID)
if leaseAuth == nil {
writeOpenAIError(w, http.StatusNotFound, "stream lease not found or expired")
return
}
powHeader, err := h.DS.GetPow(r.Context(), leaseAuth, 3)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get PoW.")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"pow_header": powHeader,
})
}
func isVercelStreamPrepareRequest(r *http.Request) bool {
if r == nil {
return false
@@ -164,6 +202,13 @@ func isVercelStreamReleaseRequest(r *http.Request) bool {
return strings.TrimSpace(r.URL.Query().Get("__stream_release")) == "1"
}
func isVercelStreamPowRequest(r *http.Request) bool {
if r == nil {
return false
}
return strings.TrimSpace(r.URL.Query().Get("__stream_pow")) == "1"
}
func vercelInternalSecret() string {
if v := strings.TrimSpace(os.Getenv("DS2API_VERCEL_INTERNAL_SECRET")); v != "" {
return v
@@ -199,6 +244,20 @@ func (h *Handler) holdStreamLease(a *auth.RequestAuth) string {
return leaseID
}
func (h *Handler) lookupStreamLeaseAuth(leaseID string) *auth.RequestAuth {
leaseID = strings.TrimSpace(leaseID)
if leaseID == "" {
return nil
}
h.leaseMu.Lock()
lease, ok := h.streamLeases[leaseID]
h.leaseMu.Unlock()
if !ok || time.Now().After(lease.ExpiresAt) {
return nil
}
return lease.Auth
}
func (h *Handler) releaseStreamLease(leaseID string) bool {
leaseID = strings.TrimSpace(leaseID)
if leaseID == "" {

View File

@@ -16,6 +16,10 @@ type mockOpenAIConfig struct {
embedProv string
historySplitEnabled bool
historySplitTurns int
currentInputEnabled bool
currentInputMin int
thinkingInjection *bool
thinkingPrompt string
}
func (m mockOpenAIConfig) ModelAliases() map[string]string { return m.aliases }
@@ -41,6 +45,17 @@ func (m mockOpenAIConfig) HistorySplitTriggerAfterTurns() int {
}
return m.historySplitTurns
}
func (m mockOpenAIConfig) CurrentInputFileEnabled() bool { return m.currentInputEnabled }
func (m mockOpenAIConfig) CurrentInputFileMinChars() int {
return m.currentInputMin
}
func (m mockOpenAIConfig) ThinkingInjectionEnabled() bool {
if m.thinkingInjection == nil {
return false
}
return *m.thinkingInjection
}
func (m mockOpenAIConfig) ThinkingInjectionPrompt() string { return m.thinkingPrompt }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{
@@ -65,6 +80,28 @@ func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
}
}
func TestNormalizeOpenAIChatRequestDisablesThinkingForNoThinkingModel(t *testing.T) {
cfg := mockOpenAIConfig{wideInput: true}
req := map[string]any{
"model": "deepseek-v4-pro-nothinking",
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
"reasoning_effort": "high",
}
out, err := promptcompat.NormalizeOpenAIChatRequest(cfg, req, "")
if err != nil {
t.Fatalf("promptcompat.NormalizeOpenAIChatRequest error: %v", err)
}
if out.ResolvedModel != "deepseek-v4-pro-nothinking" {
t.Fatalf("resolved model mismatch: got=%q", out.ResolvedModel)
}
if out.Thinking {
t.Fatalf("expected nothinking model to force thinking off")
}
if out.Search {
t.Fatalf("expected search=false for deepseek-v4-pro-nothinking, got=%v", out.Search)
}
}
func TestNormalizeOpenAIResponsesRequestWideInputPolicyFromInterface(t *testing.T) {
req := map[string]any{
"model": "deepseek-v4-flash",

View File

@@ -0,0 +1,88 @@
package history
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
)
const (
currentInputFilename = "IGNORE.txt"
currentInputContentType = "text/plain; charset=utf-8"
currentInputPurpose = "assistants"
)
func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if s.DS == nil || s.Store == nil || a == nil || !s.Store.CurrentInputFileEnabled() {
return stdReq, nil
}
threshold := s.Store.CurrentInputFileMinChars()
index, text := latestUserInputForFile(stdReq.Messages)
if index < 0 {
return stdReq, nil
}
if len([]rune(text)) < threshold {
return stdReq, nil
}
fileText := promptcompat.BuildOpenAICurrentInputContextTranscript(stdReq.Messages)
if strings.TrimSpace(fileText) == "" {
return stdReq, errors.New("current user input file produced empty transcript")
}
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: currentInputFilename,
ContentType: currentInputContentType,
Purpose: currentInputPurpose,
Data: []byte(fileText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload current user input file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload current user input file returned empty file id")
}
messages := []any{
map[string]any{
"role": "user",
"content": currentInputFilePrompt(),
},
}
stdReq.Messages = messages
stdReq.CurrentInputFileApplied = true
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
return stdReq, nil
}
func latestUserInputForFile(messages []any) (int, string) {
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role := strings.ToLower(strings.TrimSpace(shared.AsString(msg["role"])))
if role != "user" {
continue
}
text := promptcompat.NormalizeOpenAIContentForPrompt(msg["content"])
if strings.TrimSpace(text) == "" {
return -1, ""
}
return i, text
}
return -1, ""
}
func currentInputFilePrompt() string {
return promptcompat.BuildOpenAICurrentInputContextPrompt()
}

View File

@@ -2,60 +2,21 @@ package history
import (
"context"
"errors"
"fmt"
"strings"
"ds2api/internal/auth"
dsclient "ds2api/internal/deepseek/client"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
)
const (
historySplitFilename = "HISTORY.txt"
historySplitContentType = "text/plain; charset=utf-8"
historySplitPurpose = "assistants"
)
type Service struct {
Store shared.ConfigReader
DS shared.DeepSeekCaller
}
// Apply is retained for legacy compatibility only. The active split path is
// current input file handling in ApplyCurrentInputFile.
func (s Service) Apply(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if s.DS == nil || s.Store == nil || a == nil {
return stdReq, nil
}
promptMessages, historyMessages := SplitOpenAIHistoryMessages(stdReq.Messages, s.Store.HistorySplitTriggerAfterTurns())
if len(historyMessages) == 0 {
return stdReq, nil
}
historyText := promptcompat.BuildOpenAIHistoryTranscript(historyMessages)
if strings.TrimSpace(historyText) == "" {
return stdReq, errors.New("history split produced empty transcript")
}
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
Filename: historySplitFilename,
ContentType: historySplitContentType,
Purpose: historySplitPurpose,
Data: []byte(historyText),
}, 3)
if err != nil {
return stdReq, fmt.Errorf("upload history file: %w", err)
}
fileID := strings.TrimSpace(result.ID)
if fileID == "" {
return stdReq, errors.New("upload history file returned empty file id")
}
stdReq.Messages = promptMessages
stdReq.HistoryText = historyText
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(promptMessages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
return stdReq, nil
}

View File

@@ -60,13 +60,16 @@ func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.Reque
func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {}
func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) {
func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *testing.T) {
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
transcript := buildOpenAIHistoryTranscript(historyMessages)
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, "[context note]") || !strings.Contains(transcript, "compacted snapshot of the prior conversation history") {
t.Fatalf("expected compacted context note in transcript, got %q", transcript)
}
if !strings.Contains(transcript, "<begin▁of▁sentence>") {
t.Fatalf("expected serialized conversation markers, got %q", transcript)
}
@@ -76,7 +79,7 @@ func TestBuildOpenAIHistoryTranscriptUsesInjectedFileWrapper(t *testing.T) {
if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") {
t.Fatalf("expected reasoning block preserved, got %q", transcript)
}
if !strings.Contains(transcript, "<tool_calls>") {
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") {
@@ -107,7 +110,7 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
t.Fatalf("expected middle user turn to be moved into history, got %s", promptText)
}
historyText := buildOpenAIHistoryTranscript(historyMessages)
historyText := buildOpenAICurrentInputContextTranscript(historyMessages)
if !strings.Contains(historyText, "middle user turn") {
t.Fatalf("expected middle user turn in split history, got %s", historyText)
}
@@ -116,13 +119,13 @@ func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
}
}
func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
func TestApplyCurrentInputFileSkipsShortInputWhenThresholdNotReached(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
currentInputMin: 10,
},
DS: ds,
}
@@ -137,9 +140,9 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply history split failed: %v", err)
t.Fatalf("apply current input file failed: %v", err)
}
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no upload on first turn, got %d", len(ds.uploadCalls))
@@ -149,13 +152,74 @@ func TestApplyHistorySplitSkipsFirstTurn(t *testing.T) {
}
}
func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
func TestApplyThinkingInjectionAppendsLatestUserPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply thinking injection failed: %v", err)
}
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no upload for first short turn, got %d", len(ds.uploadCalls))
}
if !strings.Contains(out.FinalPrompt, "hello\n\n"+promptcompat.ThinkingInjectionMarker) {
t.Fatalf("expected thinking injection after latest user message, got %s", out.FinalPrompt)
}
}
func TestApplyThinkingInjectionUsesCustomPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
thinkingInjection: boolPtr(true),
thinkingPrompt: "custom thinking format",
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": []any{
map[string]any{"role": "user", "content": "hello"},
},
}
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply thinking injection failed: %v", err)
}
if !strings.Contains(out.FinalPrompt, "hello\n\ncustom thinking format") {
t.Fatalf("expected custom thinking injection after latest user message, got %s", out.FinalPrompt)
}
}
func TestApplyCurrentInputFileDisabledPassThrough(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: false,
},
DS: ds,
}
@@ -168,25 +232,165 @@ func TestApplyHistorySplitCarriesHistoryText(t *testing.T) {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyHistorySplit(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply history split failed: %v", err)
t.Fatalf("apply current input file failed: %v", err)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
if len(ds.uploadCalls) != 0 {
t.Fatalf("expected no uploads when both split modes are disabled, got %d", len(ds.uploadCalls))
}
if out.HistoryText != string(ds.uploadCalls[0].Data) {
t.Fatalf("expected history text to be preserved on normalized request")
if out.CurrentInputFileApplied || out.HistoryText != "" {
t.Fatalf("expected direct pass-through, got current_input=%v history=%q", out.CurrentInputFileApplied, out.HistoryText)
}
if !strings.Contains(out.FinalPrompt, "first user turn") || !strings.Contains(out.FinalPrompt, "latest user turn") {
t.Fatalf("expected original prompt context to stay inline, got %s", out.FinalPrompt)
}
}
func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *testing.T) {
func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
currentInputMin: 10,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": []any{
map[string]any{"role": "user", "content": "first turn content that is long enough"},
},
}
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply current input file failed: %v", err)
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "IGNORE.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, "<begin▁of▁sentence><User>first turn content that is long enough") {
t.Fatalf("expected serialized current user turn markers, got %q", uploadedText)
}
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") {
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
}
if !strings.Contains(out.FinalPrompt, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context instruction in live prompt, got %s", out.FinalPrompt)
}
if len(out.RefFileIDs) != 1 || out.RefFileIDs[0] != "file-inline-1" {
t.Fatalf("expected current input file id in ref_file_ids, got %#v", out.RefFileIDs)
}
}
func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
currentInputEnabled: true,
currentInputMin: 0,
thinkingInjection: boolPtr(true),
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": historySplitTestMessages(),
}
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply current input file failed: %v", err)
}
if !out.CurrentInputFileApplied {
t.Fatalf("expected current input file to apply")
}
if len(ds.uploadCalls) != 1 {
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)
}
uploadedText := string(upload.Data)
for _, want := range []string{"system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
if !strings.Contains(uploadedText, want) {
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") {
t.Fatalf("expected live prompt to stay in compacted-context mode, got %s", out.FinalPrompt)
}
if !strings.Contains(out.FinalPrompt, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context instruction in live prompt, got %s", out.FinalPrompt)
}
}
func TestApplyCurrentInputFileLeavesHistoryTextEmpty(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
currentInputEnabled: true,
},
DS: ds,
}
req := map[string]any{
"model": "deepseek-v4-flash",
"messages": historySplitTestMessages(),
}
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
if err != nil {
t.Fatalf("normalize failed: %v", err)
}
out, err := h.applyCurrentInputFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, stdReq)
if err != nil {
t.Fatalf("apply current input file failed: %v", err)
}
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)
}
}
func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -210,7 +414,7 @@ func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *te
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
}
upload := ds.uploadCalls[0]
if upload.Filename != "HISTORY.txt" {
if upload.Filename != "IGNORE.txt" {
t.Fatalf("unexpected upload filename: %q", upload.Filename)
}
if upload.Purpose != "assistants" {
@@ -220,32 +424,31 @@ func TestChatCompletionsHistorySplitUploadsHistoryFileAndKeepsLatestPrompt(t *te
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, "latest user turn") {
t.Fatalf("expected latest turn to remain live, got %s", historyText)
if !strings.Contains(historyText, "latest user turn") {
t.Fatalf("expected full context to include latest turn, got %s", historyText)
}
if ds.completionReq == nil {
t.Fatal("expected completion payload to be captured")
}
promptText, _ := ds.completionReq["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
if !strings.Contains(promptText, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context prompt, got %s", promptText)
}
if strings.Contains(promptText, "first user turn") {
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
}
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
if len(refIDs) == 0 || refIDs[0] != "file-inline-1" {
t.Fatalf("expected uploaded history file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"])
t.Fatalf("expected uploaded current input file to be first ref_file_id, got %#v", ds.completionReq["ref_file_ids"])
}
}
func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
ds := &inlineUploadDSStub{}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -274,23 +477,22 @@ func TestResponsesHistorySplitUploadsHistoryAndKeepsLatestPrompt(t *testing.T) {
t.Fatal("expected completion payload to be captured")
}
promptText, _ := ds.completionReq["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected latest turn in completion prompt, got %s", promptText)
if !strings.Contains(promptText, promptcompat.BuildOpenAICurrentInputContextPrompt()) {
t.Fatalf("expected compacted-context prompt, got %s", promptText)
}
if strings.Contains(promptText, "first user turn") {
t.Fatalf("expected historical turns removed from completion prompt, got %s", promptText)
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
}
}
func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) {
func TestChatCompletionsCurrentInputFileMapsManagedAuthFailureTo401(t *testing.T) {
ds := &inlineUploadDSStub{
uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureManagedUnauthorized, Message: "expired token"},
}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusManagedAuthStub{},
DS: ds,
@@ -315,15 +517,14 @@ func TestChatCompletionsHistorySplitMapsManagedAuthFailureTo401(t *testing.T) {
}
}
func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) {
func TestResponsesCurrentInputFileMapsDirectAuthFailureTo401(t *testing.T) {
ds := &inlineUploadDSStub{
uploadErr: &dsclient.RequestFailure{Op: "upload file", Kind: dsclient.FailureDirectUnauthorized, Message: "invalid token"},
}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -350,13 +551,12 @@ func TestResponsesHistorySplitMapsDirectAuthFailureTo401(t *testing.T) {
}
}
func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *testing.T) {
func TestChatCompletionsCurrentInputFileUploadFailureReturnsInternalServerError(t *testing.T) {
ds := &inlineUploadDSStub{uploadErr: errors.New("boom")}
h := &openAITestSurface{
Store: mockOpenAIConfig{
wideInput: true,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -378,7 +578,7 @@ func TestChatCompletionsHistorySplitUploadFailureReturnsInternalServerError(t *t
}
}
func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
func TestCurrentInputFileWorksAcrossAutoDeleteModes(t *testing.T) {
for _, mode := range []string{"none", "single", "all"} {
t.Run(mode, func(t *testing.T) {
ds := &inlineUploadDSStub{}
@@ -386,8 +586,7 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
Store: mockOpenAIConfig{
wideInput: true,
autoDeleteMode: mode,
historySplitEnabled: true,
historySplitTurns: 1,
currentInputEnabled: true,
},
Auth: streamStatusAuthStub{},
DS: ds,
@@ -408,13 +607,13 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.uploadCalls) != 1 {
t.Fatalf("expected history split upload for mode=%s, got %d", mode, len(ds.uploadCalls))
t.Fatalf("expected current input upload for mode=%s, got %d", mode, len(ds.uploadCalls))
}
if ds.completionReq == nil {
t.Fatalf("expected completion payload for mode=%s", mode)
}
promptText, _ := ds.completionReq["prompt"].(string)
if !strings.Contains(promptText, "latest user turn") || strings.Contains(promptText, "first user turn") {
if !strings.Contains(promptText, promptcompat.BuildOpenAICurrentInputContextPrompt()) || strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
t.Fatalf("unexpected prompt for mode=%s: %s", mode, promptText)
}
})
@@ -424,3 +623,7 @@ func TestHistorySplitWorksAcrossAutoDeleteModes(t *testing.T) {
func defaultToolChoicePolicy() promptcompat.ToolChoicePolicy {
return promptcompat.DefaultToolChoicePolicy()
}
func boolPtr(v bool) *bool {
return &v
}

View File

@@ -22,6 +22,15 @@ func TestGetModelRouteDirectAndAlias(t *testing.T) {
}
})
t.Run("direct_nothinking", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/models/deepseek-v4-flash-nothinking", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("direct_expert", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/models/deepseek-v4-pro", nil)
rec := httptest.NewRecorder()
@@ -48,6 +57,15 @@ func TestGetModelRouteDirectAndAlias(t *testing.T) {
t.Fatalf("expected 200 for alias, got %d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("alias_nothinking", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/models/claude-sonnet-4-6-nothinking", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for nothinking alias, got %d body=%s", rec.Code, rec.Body.String())
}
})
}
func TestGetModelRouteNotFound(t *testing.T) {

View File

@@ -0,0 +1,233 @@
package responses
import (
"context"
"io"
"net/http"
"strings"
"time"
"ds2api/internal/auth"
"ds2api/internal/config"
dsprotocol "ds2api/internal/deepseek/protocol"
openaifmt "ds2api/internal/format/openai"
"ds2api/internal/promptcompat"
"ds2api/internal/sse"
streamengine "ds2api/internal/stream"
"ds2api/internal/toolcall"
)
type responsesNonStreamResult struct {
thinking string
toolDetectionThinking string
text string
contentFilter bool
parsed toolcall.ToolCallParseResult
body map[string]any
responseMessageID int
}
func (h *Handler) handleResponsesNonStreamWithRetry(w http.ResponseWriter, ctx context.Context, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) {
attempts := 0
currentResp := resp
usagePrompt := finalPrompt
accumulatedThinking := ""
accumulatedToolDetectionThinking := ""
for {
result, ok := h.collectResponsesNonStreamAttempt(w, currentResp, responseID, model, usagePrompt, thinkingEnabled, searchEnabled, toolNames)
if !ok {
return
}
accumulatedThinking += sse.TrimContinuationOverlap(accumulatedThinking, result.thinking)
accumulatedToolDetectionThinking += sse.TrimContinuationOverlap(accumulatedToolDetectionThinking, result.toolDetectionThinking)
result.thinking = accumulatedThinking
result.toolDetectionThinking = accumulatedToolDetectionThinking
result.parsed = detectAssistantToolCalls(result.text, result.thinking, result.toolDetectionThinking, toolNames)
result.body = openaifmt.BuildResponseObjectWithToolCalls(responseID, model, usagePrompt, result.thinking, result.text, result.parsed.Calls)
if !shouldRetryResponsesNonStream(result, attempts) {
h.finishResponsesNonStreamResult(w, result, attempts, owner, responseID, toolChoice, traceID)
return
}
attempts++
config.Logger.Info("[openai_empty_retry] attempting synthetic retry", "surface", "responses", "stream", false, "retry_attempt", attempts, "parent_message_id", result.responseMessageID)
retryPow, powErr := h.DS.GetPow(ctx, a, 3)
if powErr != nil {
config.Logger.Warn("[openai_empty_retry] retry PoW fetch failed, falling back to original PoW", "surface", "responses", "stream", false, "retry_attempt", attempts, "error", powErr)
retryPow = pow
}
nextResp, err := h.DS.CallCompletion(ctx, a, clonePayloadForEmptyOutputRetry(payload, result.responseMessageID), retryPow, 3)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "Failed to get completion.")
config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "responses", "stream", false, "retry_attempt", attempts, "error", err)
return
}
usagePrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts)
currentResp = nextResp
}
}
func (h *Handler) collectResponsesNonStreamAttempt(w http.ResponseWriter, resp *http.Response, responseID, model, usagePrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) (responsesNonStreamResult, bool) {
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
return responsesNonStreamResult{}, false
}
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)
responseObj := openaifmt.BuildResponseObjectWithToolCalls(responseID, model, usagePrompt, sanitizedThinking, sanitizedText, textParsed.Calls)
return responsesNonStreamResult{
thinking: sanitizedThinking,
toolDetectionThinking: toolDetectionThinking,
text: sanitizedText,
contentFilter: result.ContentFilter,
parsed: textParsed,
body: responseObj,
responseMessageID: result.ResponseMessageID,
}, true
}
func (h *Handler) finishResponsesNonStreamResult(w http.ResponseWriter, result responsesNonStreamResult, attempts int, owner, responseID string, toolChoice promptcompat.ToolChoicePolicy, traceID string) {
if len(result.parsed.Calls) == 0 && writeUpstreamEmptyOutputError(w, result.text, result.thinking, result.contentFilter) {
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "responses", "stream", false, "retry_attempts", attempts, "success_source", "none", "content_filter", result.contentFilter)
return
}
logResponsesToolPolicyRejection(traceID, toolChoice, result.parsed, "text")
if toolChoice.IsRequired() && len(result.parsed.Calls) == 0 {
writeOpenAIErrorWithCode(w, http.StatusUnprocessableEntity, "tool_choice requires at least one valid tool call.", "tool_choice_violation")
return
}
h.getResponseStore().put(owner, responseID, result.body)
writeJSON(w, http.StatusOK, result.body)
source := "first_attempt"
if attempts > 0 {
source = "synthetic_retry"
}
config.Logger.Info("[openai_empty_retry] completed", "surface", "responses", "stream", false, "retry_attempts", attempts, "success_source", source)
}
func shouldRetryResponsesNonStream(result responsesNonStreamResult, attempts int) bool {
return emptyOutputRetryEnabled() &&
attempts < emptyOutputRetryMaxAttempts() &&
!result.contentFilter &&
len(result.parsed.Calls) == 0 &&
strings.TrimSpace(result.text) == ""
}
func (h *Handler) handleResponsesStreamWithRetry(w http.ResponseWriter, r *http.Request, a *auth.RequestAuth, resp *http.Response, payload map[string]any, pow, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) {
streamRuntime, initialType, ok := h.prepareResponsesStreamRuntime(w, resp, owner, responseID, model, finalPrompt, thinkingEnabled, searchEnabled, toolNames, toolChoice, traceID)
if !ok {
return
}
attempts := 0
currentResp := resp
for {
terminalWritten, retryable := h.consumeResponsesStreamAttempt(r, currentResp, streamRuntime, initialType, thinkingEnabled, attempts < emptyOutputRetryMaxAttempts())
if terminalWritten {
logResponsesStreamTerminal(streamRuntime, attempts)
return
}
if !retryable || !emptyOutputRetryEnabled() || attempts >= emptyOutputRetryMaxAttempts() {
streamRuntime.finalize("stop", false)
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "responses", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
return
}
attempts++
config.Logger.Info("[openai_empty_retry] attempting synthetic retry", "surface", "responses", "stream", true, "retry_attempt", attempts, "parent_message_id", streamRuntime.responseMessageID)
retryPow, powErr := h.DS.GetPow(r.Context(), a, 3)
if powErr != nil {
config.Logger.Warn("[openai_empty_retry] retry PoW fetch failed, falling back to original PoW", "surface", "responses", "stream", true, "retry_attempt", attempts, "error", powErr)
retryPow = pow
}
nextResp, err := h.DS.CallCompletion(r.Context(), a, clonePayloadForEmptyOutputRetry(payload, streamRuntime.responseMessageID), retryPow, 3)
if err != nil {
streamRuntime.failResponse(http.StatusInternalServerError, "Failed to get completion.", "error")
config.Logger.Warn("[openai_empty_retry] retry request failed", "surface", "responses", "stream", true, "retry_attempt", attempts, "error", err)
return
}
if nextResp.StatusCode != http.StatusOK {
defer func() { _ = nextResp.Body.Close() }()
body, _ := io.ReadAll(nextResp.Body)
streamRuntime.failResponse(nextResp.StatusCode, strings.TrimSpace(string(body)), "error")
return
}
streamRuntime.finalPrompt = usagePromptWithEmptyOutputRetry(finalPrompt, attempts)
currentResp = nextResp
}
}
func (h *Handler) prepareResponsesStreamRuntime(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) (*responsesStreamRuntime, string, bool) {
if resp.StatusCode != http.StatusOK {
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
return nil, "", false
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
rc := http.NewResponseController(w)
_, canFlush := w.(http.Flusher)
initialType := "text"
if thinkingEnabled {
initialType = "thinking"
}
streamRuntime := newResponsesStreamRuntime(
w, rc, canFlush, responseID, model, finalPrompt, thinkingEnabled, searchEnabled,
h.compatStripReferenceMarkers(), toolNames, len(toolNames) > 0,
h.toolcallFeatureMatchEnabled() && h.toolcallEarlyEmitHighConfidence(),
toolChoice, traceID, func(obj map[string]any) {
h.getResponseStore().put(owner, responseID, obj)
},
)
streamRuntime.sendCreated()
return streamRuntime, initialType, true
}
func (h *Handler) consumeResponsesStreamAttempt(r *http.Request, resp *http.Response, streamRuntime *responsesStreamRuntime, initialType string, thinkingEnabled bool, allowDeferEmpty bool) (bool, bool) {
defer func() { _ = resp.Body.Close() }()
finalReason := "stop"
streamengine.ConsumeSSE(streamengine.ConsumeConfig{
Context: r.Context(),
Body: resp.Body,
ThinkingEnabled: thinkingEnabled,
InitialType: initialType,
KeepAliveInterval: time.Duration(dsprotocol.KeepAliveTimeout) * time.Second,
IdleTimeout: time.Duration(dsprotocol.StreamIdleTimeout) * time.Second,
MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount,
}, streamengine.ConsumeHooks{
OnParsed: streamRuntime.onParsed,
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
finalReason = "content_filter"
}
},
})
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
if terminalWritten {
return true, false
}
return false, true
}
func logResponsesStreamTerminal(streamRuntime *responsesStreamRuntime, attempts int) {
source := "first_attempt"
if attempts > 0 {
source = "synthetic_retry"
}
if streamRuntime.failed {
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "responses", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
return
}
config.Logger.Info("[openai_empty_retry] completed", "surface", "responses", "stream", true, "retry_attempts", attempts, "success_source", source)
}

View File

@@ -11,6 +11,7 @@ import (
"ds2api/internal/httpapi/openai/history"
"ds2api/internal/httpapi/openai/shared"
"ds2api/internal/promptcompat"
"ds2api/internal/toolcall"
"ds2api/internal/toolstream"
)
@@ -35,11 +36,17 @@ func (h *Handler) compatStripReferenceMarkers() bool {
return shared.CompatStripReferenceMarkers(h.Store)
}
func (h *Handler) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
func (h *Handler) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
if h == nil {
return stdReq, nil
}
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil || out.CurrentInputFileApplied {
return out, err
}
return out, nil
}
func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
@@ -79,7 +86,7 @@ func writeOpenAIInlineFileError(w http.ResponseWriter, err error) {
files.WriteInlineFileError(w, err)
}
func mapHistorySplitError(err error) (int, string) {
func mapCurrentInputFileError(err error) (int, string) {
return history.MapError(err)
}
@@ -103,6 +110,26 @@ func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string,
return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter)
}
func emptyOutputRetryEnabled() bool {
return shared.EmptyOutputRetryEnabled()
}
func emptyOutputRetryMaxAttempts() int {
return shared.EmptyOutputRetryMaxAttempts()
}
func clonePayloadForEmptyOutputRetry(payload map[string]any, parentMessageID int) map[string]any {
return shared.ClonePayloadForEmptyOutputRetry(payload, parentMessageID)
}
func usagePromptWithEmptyOutputRetry(originalPrompt string, retryAttempts int) string {
return shared.UsagePromptWithEmptyOutputRetry(originalPrompt, retryAttempts)
}
func filterIncrementalToolCallDeltasByAllowed(deltas []toolstream.ToolCallDelta, seenNames map[int]string) []toolstream.ToolCallDelta {
return shared.FilterIncrementalToolCallDeltasByAllowed(deltas, seenNames)
}
func detectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
return shared.DetectAssistantToolCalls(text, exposedThinking, detectionThinking, toolNames)
}

View File

@@ -85,9 +85,9 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, http.StatusBadRequest, err.Error())
return
}
stdReq, err = h.applyHistorySplit(r.Context(), a, stdReq)
stdReq, err = h.applyCurrentInputFile(r.Context(), a, stdReq)
if err != nil {
status, message := mapHistorySplitError(err)
status, message := mapCurrentInputFileError(err)
writeOpenAIError(w, status, message)
return
}
@@ -115,10 +115,10 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
responseID := "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if stdReq.Stream {
h.handleResponsesStream(w, r, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
h.handleResponsesStreamWithRetry(w, r, a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
return
}
h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
h.handleResponsesNonStreamWithRetry(w, r.Context(), a, resp, payload, pow, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
}
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice promptcompat.ToolChoicePolicy, traceID string) {
@@ -131,14 +131,15 @@ 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)
}
if writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) {
textParsed := detectAssistantToolCalls(sanitizedText, sanitizedThinking, toolDetectionThinking, toolNames)
if len(textParsed.Calls) == 0 && writeUpstreamEmptyOutputError(w, sanitizedText, sanitizedThinking, result.ContentFilter) {
return
}
textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
callCount := len(textParsed.Calls)
@@ -147,7 +148,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
return
}
responseObj := openaifmt.BuildResponseObject(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, toolNames)
responseObj := openaifmt.BuildResponseObjectWithToolCalls(responseID, model, finalPrompt, sanitizedThinking, sanitizedText, textParsed.Calls)
h.getResponseStore().put(owner, responseID, responseObj)
writeJSON(w, http.StatusOK, responseObj)
}
@@ -205,8 +206,12 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
MaxKeepAliveNoInput: dsprotocol.MaxKeepaliveCount,
}, streamengine.ConsumeHooks{
OnParsed: streamRuntime.onParsed,
OnFinalize: func(_ streamengine.StopReason, _ error) {
streamRuntime.finalize()
OnFinalize: func(reason streamengine.StopReason, _ error) {
if string(reason) == "content_filter" {
streamRuntime.finalize("content_filter", false)
return
}
streamRuntime.finalize("stop", false)
},
})
}

View File

@@ -34,24 +34,29 @@ type responsesStreamRuntime struct {
toolCallsEmitted bool
toolCallsDoneEmitted bool
sieve toolstream.State
thinking strings.Builder
text strings.Builder
visibleText strings.Builder
streamToolCallIDs map[int]string
functionItemIDs map[int]string
functionOutputIDs map[int]int
functionArgs map[int]string
functionDone map[int]bool
functionAdded map[int]bool
functionNames map[int]string
messageItemID string
messageOutputID int
nextOutputID int
messageAdded bool
messagePartAdded bool
sequence int
failed bool
sieve toolstream.State
thinking strings.Builder
toolDetectionThinking strings.Builder
text strings.Builder
visibleText strings.Builder
responseMessageID int
streamToolCallIDs map[int]string
functionItemIDs map[int]string
functionOutputIDs map[int]int
functionArgs map[int]string
functionDone map[int]bool
functionAdded map[int]bool
functionNames map[int]string
messageItemID string
messageOutputID int
nextOutputID int
messageAdded bool
messagePartAdded bool
sequence int
failed bool
finalErrorStatus int
finalErrorMessage string
finalErrorCode string
persistResponse func(obj map[string]any)
}
@@ -102,6 +107,9 @@ func newResponsesStreamRuntime(
func (s *responsesStreamRuntime) failResponse(status int, message, code string) {
s.failed = true
s.finalErrorStatus = status
s.finalErrorMessage = message
s.finalErrorCode = code
failedResp := map[string]any{
"id": s.responseID,
"type": "response",
@@ -125,15 +133,20 @@ func (s *responsesStreamRuntime) failResponse(status int, message, code string)
s.sendDone()
}
func (s *responsesStreamRuntime) finalize() {
func (s *responsesStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) bool {
s.failed = false
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 := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
textParsed := detectAssistantToolCalls(finalText, finalThinking, finalToolDetectionThinking, s.toolNames)
detected := textParsed.Calls
s.logToolPolicyRejections(textParsed)
@@ -148,12 +161,18 @@ func (s *responsesStreamRuntime) finalize() {
if s.toolChoice.IsRequired() && len(detected) == 0 {
s.failResponse(http.StatusUnprocessableEntity, "tool_choice requires at least one valid tool call.", "tool_choice_violation")
return
return true
}
if len(detected) == 0 && strings.TrimSpace(finalText) == "" {
status, message, code := upstreamEmptyOutputDetail(false, finalText, finalThinking)
status, message, code := upstreamEmptyOutputDetail(finishReason == "content_filter", finalText, finalThinking)
if deferEmptyOutput {
s.finalErrorStatus = status
s.finalErrorMessage = message
s.finalErrorCode = code
return false
}
s.failResponse(status, message, code)
return
return true
}
s.closeIncompleteFunctionItems()
@@ -163,6 +182,7 @@ func (s *responsesStreamRuntime) finalize() {
}
s.sendEvent("response.completed", openaifmt.BuildResponsesCompletedPayload(obj))
s.sendDone()
return true
}
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed toolcall.ToolCallParseResult) {
@@ -186,11 +206,23 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
if !parsed.Parsed {
return streamengine.ParsedDecision{}
}
if parsed.ContentFilter || parsed.ErrorMessage != "" || parsed.Stop {
if parsed.ResponseMessageID > 0 {
s.responseMessageID = parsed.ResponseMessageID
}
if parsed.ContentFilter || parsed.ErrorMessage != "" {
return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")}
}
if parsed.Stop {
return streamengine.ParsedDecision{Stop: true}
}
contentSeen := false
for _, p := range parsed.ToolDetectionThinkingParts {
trimmed := sse.TrimContinuationOverlap(s.toolDetectionThinking.String(), p.Text)
if trimmed != "" {
s.toolDetectionThinking.WriteString(trimmed)
}
}
for _, p := range parsed.Parts {
cleanedText := cleanVisibleOutput(p.Text, s.stripReferenceMarkers)
if cleanedText == "" {

View File

@@ -232,6 +232,76 @@ func TestHandleResponsesStreamFailsWhenUpstreamHasOnlyThinking(t *testing.T) {
}
}
func TestHandleResponsesStreamPromotesThinkingToolCallsOnFinalizeWithoutMidstreamIntercept(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(path, value string) string {
b, _ := json.Marshal(map[string]any{
"p": path,
"v": value,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("response/thinking_content", `<tool_calls><invoke name="read_file"><parameter name="path">README.MD</parameter></invoke></tool_calls>`) + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, []string{"read_file"}, 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.function_call_arguments.done") {
t.Fatalf("expected finalize fallback function call event, got %s", body)
}
if strings.Contains(body, "event: response.failed") {
t.Fatalf("did not expect response.failed, body=%s", body)
}
}
func TestHandleResponsesStreamPromotesHiddenThinkingDSMLToolCallsOnFinalize(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder()
sseLine := func(path, value string) string {
b, _ := json.Marshal(map[string]any{
"p": path,
"v": value,
})
return "data: " + string(b) + "\n"
}
streamBody := sseLine("response/thinking_content", `<|DSML|tool_calls><|DSML|invoke name="read_file"><|DSML|parameter name="path">README.MD</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>`) + "data: [DONE]\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(streamBody)),
}
policy := promptcompat.ToolChoicePolicy{
Mode: promptcompat.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesStream(rec, req, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, policy, "")
body := rec.Body.String()
if strings.Contains(body, "event: response.reasoning.delta") {
t.Fatalf("did not expect hidden reasoning delta in stream body, got %s", body)
}
if !strings.Contains(body, "event: response.function_call_arguments.done") {
t.Fatalf("expected hidden-thinking fallback function call event, got %s", body)
}
if strings.Contains(body, "event: response.failed") {
t.Fatalf("did not expect response.failed, body=%s", body)
}
}
func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
@@ -258,7 +328,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
}
}
func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t *testing.T) {
func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayloadWhenTextExists(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
@@ -351,6 +421,65 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testin
}
}
func TestHandleResponsesNonStreamPromotesThinkingToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`data: {"p":"response/thinking_content","v":"<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.MD</parameter></invoke></tool_calls>"}` + "\n" +
`data: [DONE]` + "\n",
)),
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-v4-pro", "prompt", true, false, []string{"read_file"}, promptcompat.DefaultToolChoicePolicy(), "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
output, _ := out["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", out["output"])
}
first, _ := output[0].(map[string]any)
if got := asString(first["type"]); got != "function_call" {
t.Fatalf("expected function_call output, got %#v", first["type"])
}
}
func TestHandleResponsesNonStreamPromotesHiddenThinkingDSMLToolCallsWhenTextEmpty(t *testing.T) {
h := &Handler{}
rec := httptest.NewRecorder()
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`data: {"p":"response/thinking_content","v":"<|DSML|tool_calls><|DSML|invoke name=\"read_file\"><|DSML|parameter name=\"path\">README.MD</|DSML|parameter></|DSML|invoke></|DSML|tool_calls>"}` + "\n" +
`data: [DONE]` + "\n",
)),
}
policy := promptcompat.ToolChoicePolicy{
Mode: promptcompat.ToolChoiceRequired,
Allowed: map[string]struct{}{"read_file": {}},
}
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_hidden", "deepseek-v4-pro", "prompt", false, false, []string{"read_file"}, policy, "")
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for hidden thinking tool calls, got %d body=%s", rec.Code, rec.Body.String())
}
out := decodeJSONBody(t, rec.Body.String())
output, _ := out["output"].([]any)
if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", out["output"])
}
first, _ := output[0].(map[string]any)
if got := asString(first["type"]); got != "function_call" {
t.Fatalf("expected function_call output, got %#v", first["type"])
}
if strings.Contains(rec.Body.String(), "reasoning") {
t.Fatalf("did not expect hidden reasoning in response body, got %s", rec.Body.String())
}
}
func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
scanner := bufio.NewScanner(strings.NewReader(body))
matched := false

View File

@@ -0,0 +1,26 @@
package shared
import (
"strings"
"ds2api/internal/toolcall"
)
func DetectAssistantToolCalls(text, exposedThinking, detectionThinking string, toolNames []string) toolcall.ToolCallParseResult {
textParsed := toolcall.ParseStandaloneToolCallsDetailed(text, toolNames)
if len(textParsed.Calls) > 0 {
return textParsed
}
if strings.TrimSpace(text) != "" {
return textParsed
}
thinking := detectionThinking
if strings.TrimSpace(thinking) == "" {
thinking = exposedThinking
}
thinkingParsed := toolcall.ParseStandaloneToolCallsDetailed(thinking, toolNames)
if len(thinkingParsed.Calls) > 0 {
return thinkingParsed
}
return textParsed
}

View File

@@ -45,6 +45,10 @@ type ConfigReader interface {
AutoDeleteSessions() bool
HistorySplitEnabled() bool
HistorySplitTriggerAfterTurns() int
CurrentInputFileEnabled() bool
CurrentInputFileMinChars() int
ThinkingInjectionEnabled() bool
ThinkingInjectionPrompt() string
}
type Deps struct {

View File

@@ -0,0 +1,56 @@
package shared
import "strings"
const EmptyOutputRetrySuffix = "Previous reply had no visible output. Please regenerate the visible final answer or tool call now."
func EmptyOutputRetryEnabled() bool {
return true
}
func EmptyOutputRetryMaxAttempts() int {
return 1
}
func ClonePayloadWithEmptyOutputRetryPrompt(payload map[string]any) map[string]any {
return ClonePayloadForEmptyOutputRetry(payload, 0)
}
// ClonePayloadForEmptyOutputRetry creates a retry payload with the suffix
// appended and, if parentMessageID > 0, sets parent_message_id so the
// retry is submitted as a proper follow-up turn in the same DeepSeek
// session rather than a disconnected root message.
func ClonePayloadForEmptyOutputRetry(payload map[string]any, parentMessageID int) map[string]any {
clone := make(map[string]any, len(payload))
for k, v := range payload {
clone[k] = v
}
original, _ := payload["prompt"].(string)
clone["prompt"] = AppendEmptyOutputRetrySuffix(original)
if parentMessageID > 0 {
clone["parent_message_id"] = parentMessageID
}
return clone
}
func AppendEmptyOutputRetrySuffix(prompt string) string {
prompt = strings.TrimRight(prompt, "\r\n\t ")
if prompt == "" {
return EmptyOutputRetrySuffix
}
return prompt + "\n\n" + EmptyOutputRetrySuffix
}
func UsagePromptWithEmptyOutputRetry(originalPrompt string, retryAttempts int) string {
if retryAttempts <= 0 {
return originalPrompt
}
parts := make([]string, 0, retryAttempts+1)
parts = append(parts, originalPrompt)
next := originalPrompt
for i := 0; i < retryAttempts; i++ {
next = AppendEmptyOutputRetrySuffix(next)
parts = append(parts, next)
}
return strings.Join(parts, "\n")
}

View File

@@ -0,0 +1,21 @@
package shared
import "ds2api/internal/promptcompat"
func ApplyThinkingInjection(store ConfigReader, stdReq promptcompat.StandardRequest) promptcompat.StandardRequest {
if store == nil || !store.ThinkingInjectionEnabled() || !stdReq.Thinking {
return stdReq
}
messages, changed := promptcompat.AppendThinkingInjectionPromptToLatestUser(stdReq.Messages, store.ThinkingInjectionPrompt())
if !changed {
return stdReq
}
finalPrompt, toolNames := promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
if len(toolNames) == 0 && len(stdReq.ToolNames) > 0 {
toolNames = stdReq.ToolNames
}
stdReq.Messages = messages
stdReq.FinalPrompt = finalPrompt
stdReq.ToolNames = toolNames
return stdReq
}

View File

@@ -66,6 +66,44 @@ func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ strin
return nil
}
type streamStatusDSSeqStub struct {
resps []*http.Response
payloads []map[string]any
}
func (m *streamStatusDSSeqStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "session-id", nil
}
func (m *streamStatusDSSeqStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
return "pow", nil
}
func (m *streamStatusDSSeqStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ dsclient.UploadFileRequest, _ int) (*dsclient.UploadFileResult, error) {
return &dsclient.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil
}
func (m *streamStatusDSSeqStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) {
clone := make(map[string]any, len(payload))
for k, v := range payload {
clone[k] = v
}
m.payloads = append(m.payloads, clone)
idx := len(m.payloads) - 1
if idx >= len(m.resps) {
idx = len(m.resps) - 1
}
return m.resps[idx], nil
}
func (m *streamStatusDSSeqStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*dsclient.DeleteSessionResult, error) {
return &dsclient.DeleteSessionResult{Success: true}, nil
}
func (m *streamStatusDSSeqStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") {
@@ -78,6 +116,12 @@ func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
}
}
func newOpenAITestRouter(h *openAITestSurface) http.Handler {
r := chi.NewRouter()
registerOpenAITestRoutes(r, h)
return r
}
func captureStatusMiddleware(statuses *[]int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -239,6 +283,133 @@ func TestChatCompletionsStreamEmitsFailureFrameWhenUpstreamOutputEmpty(t *testin
}
}
func TestChatCompletionsStreamRetriesEmptyOutputOnSameSession(t *testing.T) {
ds := &streamStatusDSSeqStub{resps: []*http.Response{
makeOpenAISSEHTTPResponse(`data: {"response_message_id":42,"p":"response/thinking_content","v":"plan"}`, "data: [DONE]"),
makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"visible"}`, "data: [DONE]"),
}}
h := &openAITestSurface{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody := `{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hi"}],"stream":true}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
newOpenAITestRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.payloads) != 2 {
t.Fatalf("expected one synthetic retry call, got %d", len(ds.payloads))
}
if ds.payloads[0]["chat_session_id"] != ds.payloads[1]["chat_session_id"] {
t.Fatalf("expected retry to reuse session, payloads=%#v", ds.payloads)
}
retryPrompt := asString(ds.payloads[1]["prompt"])
if !strings.Contains(retryPrompt, "Previous reply had no visible output. Please regenerate the visible final answer or tool call now.") {
t.Fatalf("expected retry suffix in prompt, got %q", retryPrompt)
}
// Verify multi-turn chaining: retry must set parent_message_id from first call's response_message_id.
if parentID, ok := ds.payloads[1]["parent_message_id"].(int); !ok || parentID != 42 {
t.Fatalf("expected retry parent_message_id=42, got %#v", ds.payloads[1]["parent_message_id"])
}
frames, done := parseSSEDataFrames(t, rec.Body.String())
if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String())
}
doneCount := strings.Count(rec.Body.String(), "data: [DONE]")
if doneCount != 1 {
t.Fatalf("expected one [DONE], got %d body=%s", doneCount, rec.Body.String())
}
if len(frames) != 3 {
t.Fatalf("expected reasoning, content, finish frames, got %#v body=%s", frames, rec.Body.String())
}
id := asString(frames[0]["id"])
for _, frame := range frames[1:] {
if asString(frame["id"]) != id {
t.Fatalf("expected same completion id across retry stream, frames=%#v", frames)
}
}
choices, _ := frames[1]["choices"].([]any)
choice, _ := choices[0].(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if asString(delta["content"]) != "visible" {
t.Fatalf("expected retry content delta, got %#v body=%s", delta, rec.Body.String())
}
}
func TestChatCompletionsNonStreamRetriesThinkingOnlyOutput(t *testing.T) {
ds := &streamStatusDSSeqStub{resps: []*http.Response{
makeOpenAISSEHTTPResponse(`data: {"response_message_id":99,"p":"response/thinking_content","v":"plan"}`, "data: [DONE]"),
makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"visible"}`, "data: [DONE]"),
}}
h := &openAITestSurface{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody := `{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hi"}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
newOpenAITestRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 after retry, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.payloads) != 2 {
t.Fatalf("expected one synthetic retry call, got %d", len(ds.payloads))
}
// Verify multi-turn chaining.
if parentID, ok := ds.payloads[1]["parent_message_id"].(int); !ok || parentID != 99 {
t.Fatalf("expected retry parent_message_id=99, got %#v", ds.payloads[1]["parent_message_id"])
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
}
choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
if asString(message["content"]) != "visible" {
t.Fatalf("expected retry visible content, got %#v", message)
}
if !strings.Contains(asString(message["reasoning_content"]), "plan") {
t.Fatalf("expected first-attempt reasoning to be preserved, got %#v", message)
}
}
func TestChatCompletionsContentFilterDoesNotRetry(t *testing.T) {
ds := &streamStatusDSSeqStub{resps: []*http.Response{
makeOpenAISSEHTTPResponse(`data: {"code":"content_filter"}`),
makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"visible"}`, "data: [DONE]"),
}}
h := &openAITestSurface{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
newOpenAITestRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected content_filter 400, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.payloads) != 1 {
t.Fatalf("expected no retry on content_filter, got %d calls", len(ds.payloads))
}
}
func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) {
statuses := make([]int, 0, 1)
h := &openAITestSurface{
@@ -287,6 +458,94 @@ func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) {
}
}
func TestResponsesStreamRetriesThinkingOnlyOutput(t *testing.T) {
ds := &streamStatusDSSeqStub{resps: []*http.Response{
makeOpenAISSEHTTPResponse(`data: {"response_message_id":77,"p":"response/thinking_content","v":"plan"}`, "data: [DONE]"),
makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"visible"}`, "data: [DONE]"),
}}
h := &openAITestSurface{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody := `{"model":"deepseek-v4-pro","input":"hi","stream":true}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
newOpenAITestRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.payloads) != 2 {
t.Fatalf("expected one synthetic retry call, got %d", len(ds.payloads))
}
// Verify multi-turn chaining.
if parentID, ok := ds.payloads[1]["parent_message_id"].(int); !ok || parentID != 77 {
t.Fatalf("expected retry parent_message_id=77, got %#v", ds.payloads[1]["parent_message_id"])
}
body := rec.Body.String()
if strings.Contains(body, "response.failed") {
t.Fatalf("did not expect premature response.failed, body=%s", body)
}
if !strings.Contains(body, "response.reasoning.delta") || !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "response.completed") {
t.Fatalf("expected reasoning, text delta, and completed events, body=%s", body)
}
if strings.Count(body, "data: [DONE]") != 1 {
t.Fatalf("expected one [DONE], body=%s", body)
}
}
func TestResponsesNonStreamRetriesThinkingOnlyOutput(t *testing.T) {
ds := &streamStatusDSSeqStub{resps: []*http.Response{
makeOpenAISSEHTTPResponse(`data: {"response_message_id":88,"p":"response/thinking_content","v":"plan"}`, "data: [DONE]"),
makeOpenAISSEHTTPResponse(`data: {"p":"response/content","v":"visible"}`, "data: [DONE]"),
}}
h := &openAITestSurface{
Store: mockOpenAIConfig{wideInput: true},
Auth: streamStatusAuthStub{},
DS: ds,
}
reqBody := `{"model":"deepseek-v4-pro","input":"hi","stream":false}`
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer direct-token")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
newOpenAITestRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 after retry, got %d body=%s", rec.Code, rec.Body.String())
}
if len(ds.payloads) != 2 {
t.Fatalf("expected one synthetic retry call, got %d", len(ds.payloads))
}
// Verify multi-turn chaining.
if parentID, ok := ds.payloads[1]["parent_message_id"].(int); !ok || parentID != 88 {
t.Fatalf("expected retry parent_message_id=88, got %#v", ds.payloads[1]["parent_message_id"])
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
}
if asString(out["output_text"]) != "visible" {
t.Fatalf("expected retry visible output_text, got %#v", out["output_text"])
}
output, _ := out["output"].([]any)
if len(output) == 0 {
t.Fatalf("expected output items, got %#v", out)
}
item, _ := output[0].(map[string]any)
content, _ := item["content"].([]any)
if len(content) == 0 {
t.Fatalf("expected content entries, got %#v", item)
}
reasoning, _ := content[0].(map[string]any)
if asString(reasoning["type"]) != "reasoning" || !strings.Contains(asString(reasoning["text"]), "plan") {
t.Fatalf("expected preserved reasoning entry, got %#v", content)
}
}
func TestResponsesNonStreamUsageIgnoresPromptAndOutputTokenUsage(t *testing.T) {
statuses := make([]int, 0, 1)
h := &openAITestSurface{

View File

@@ -83,8 +83,14 @@ func (h *openAITestSurface) ChatCompletions(w http.ResponseWriter, r *http.Reque
h.chatHandler().ChatCompletions(w, r)
}
func (h *openAITestSurface) applyHistorySplit(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
return history.Service{Store: h.Store, DS: h.DS}.Apply(ctx, a, stdReq)
func (h *openAITestSurface) applyCurrentInputFile(ctx context.Context, a *auth.RequestAuth, stdReq promptcompat.StandardRequest) (promptcompat.StandardRequest, error) {
stdReq = shared.ApplyThinkingInjection(h.Store, stdReq)
svc := history.Service{Store: h.Store, DS: h.DS}
out, err := svc.ApplyCurrentInputFile(ctx, a, stdReq)
if err != nil || out.CurrentInputFileApplied {
return out, err
}
return out, nil
}
func (h *openAITestSurface) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error {
@@ -105,8 +111,8 @@ func splitOpenAIHistoryMessages(messages []any, triggerAfterTurns int) ([]any, [
return history.SplitOpenAIHistoryMessages(messages, triggerAfterTurns)
}
func buildOpenAIHistoryTranscript(messages []any) string {
return promptcompat.BuildOpenAIHistoryTranscript(messages)
func buildOpenAICurrentInputContextTranscript(messages []any) string {
return promptcompat.BuildOpenAICurrentInputContextTranscript(messages)
}
func writeOpenAIError(w http.ResponseWriter, status int, message string) {

View File

@@ -58,6 +58,33 @@ async function fetchStreamPrepare(req, rawBody) {
};
}
async function fetchStreamPow(req, leaseID) {
const url = buildInternalGoURL(req);
url.searchParams.set('__stream_pow', '1');
const upstream = await fetch(url.toString(), {
method: 'POST',
headers: buildInternalGoHeaders(req, { withInternalToken: true, withContentType: true }),
body: Buffer.from(JSON.stringify({ lease_id: leaseID })),
});
const text = await upstream.text();
let body = {};
try {
body = JSON.parse(text || '{}');
} catch (_err) {
body = {};
}
return {
ok: upstream.ok,
status: upstream.status,
contentType: upstream.headers.get('content-type') || 'application/json',
text,
body,
};
}
function relayPreparedFailure(res, prep) {
if (prep.status === 401 && looksLikeVercelAuthPage(prep.text)) {
writeOpenAIError(
@@ -195,6 +222,7 @@ module.exports = {
header,
readRawBody,
fetchStreamPrepare,
fetchStreamPow,
relayPreparedFailure,
safeReadText,
buildInternalGoURL,

View File

@@ -25,6 +25,7 @@ const {
asString,
isAbortError,
fetchStreamPrepare,
fetchStreamPow,
relayPreparedFailure,
createLeaseReleaser,
} = require('./http_internal');
@@ -33,6 +34,10 @@ const {
} = require('./dedupe');
const DEEPSEEK_COMPLETION_URL = 'https://chat.deepseek.com/api/v0/chat/completion';
const DEEPSEEK_CONTINUE_URL = 'https://chat.deepseek.com/api/v0/chat/continue';
const EMPTY_OUTPUT_RETRY_SUFFIX = 'Previous reply had no visible output. Please regenerate the visible final answer or tool call now.';
const EMPTY_OUTPUT_RETRY_MAX_ATTEMPTS = 1;
const AUTO_CONTINUE_MAX_ROUNDS = 8;
async function handleVercelStream(req, res, rawBody, payload) {
const prep = await fetchStreamPrepare(req, rawBody);
@@ -45,7 +50,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
const sessionID = asString(prep.body.session_id) || `chatcmpl-${Date.now()}`;
const leaseID = asString(prep.body.lease_id);
const deepseekToken = asString(prep.body.deepseek_token);
const powHeader = asString(prep.body.pow_header);
const initialPowHeader = asString(prep.body.pow_header);
const completionPayload = prep.body.payload && typeof prep.body.payload === 'object' ? prep.body.payload : null;
const finalPrompt = asString(prep.body.final_prompt);
const thinkingEnabled = toBool(prep.body.thinking_enabled);
@@ -55,7 +60,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
const emitEarlyToolDeltas = toolPolicy.emitEarlyToolDeltas;
const stripReferenceMarkers = boolDefaultTrue(prep.body.compat && prep.body.compat.strip_reference_markers);
if (!model || !leaseID || !deepseekToken || !powHeader || !completionPayload) {
if (!model || !leaseID || !deepseekToken || !initialPowHeader || !completionPayload) {
writeOpenAIError(res, 500, 'invalid vercel prepare response');
return;
}
@@ -84,23 +89,66 @@ async function handleVercelStream(req, res, rawBody, payload) {
res.on('close', onResClose);
try {
let completionRes;
try {
completionRes = await fetch(DEEPSEEK_COMPLETION_URL, {
method: 'POST',
headers: {
...BASE_HEADERS,
authorization: `Bearer ${deepseekToken}`,
'x-ds-pow-response': powHeader,
},
body: JSON.stringify(completionPayload),
signal: upstreamController.signal,
});
} catch (err) {
if (clientClosed || isAbortError(err)) {
return;
let currentPowHeader = initialPowHeader;
const refreshPowHeader = async (roundType) => {
try {
const pow = await fetchStreamPow(req, leaseID);
const nextPowHeader = asString(pow.body && pow.body.pow_header);
if (pow.ok && nextPowHeader) {
currentPowHeader = nextPowHeader;
return currentPowHeader;
}
console.warn('[vercel_stream_pow] refresh failed, reusing previous PoW', {
round_type: roundType,
status: pow.status || 0,
});
} catch (err) {
if (clientClosed || isAbortError(err)) {
return '';
}
console.warn('[vercel_stream_pow] refresh failed, reusing previous PoW', {
round_type: roundType,
error: err,
});
}
throw err;
return currentPowHeader;
};
const fetchDeepSeekStream = async (url, bodyPayload, powHeader) => {
try {
return await fetch(url, {
method: 'POST',
headers: {
...BASE_HEADERS,
authorization: `Bearer ${deepseekToken}`,
'x-ds-pow-response': powHeader,
},
body: JSON.stringify(bodyPayload),
signal: upstreamController.signal,
});
} catch (err) {
if (clientClosed || isAbortError(err)) {
return null;
}
throw err;
}
};
const fetchCompletion = (bodyPayload) => fetchDeepSeekStream(DEEPSEEK_COMPLETION_URL, bodyPayload, currentPowHeader);
const fetchContinue = async (messageID) => {
const powHeader = await refreshPowHeader('continue');
if (!powHeader) {
return null;
}
return fetchDeepSeekStream(DEEPSEEK_CONTINUE_URL, {
chat_session_id: sessionID,
message_id: messageID,
fallback_to_resume: true,
}, powHeader);
};
let completionRes = await fetchCompletion(completionPayload);
if (completionRes === null) {
return;
}
if (clientClosed) {
return;
@@ -126,6 +174,7 @@ async function handleVercelStream(req, res, rawBody, payload) {
let currentType = thinkingEnabled ? 'thinking' : 'text';
let thinkingText = '';
let outputText = '';
let usagePrompt = finalPrompt;
const toolSieveEnabled = toolPolicy.toolSieveEnabled;
const toolSieveState = createToolSieveState();
let toolCallsEmitted = false;
@@ -133,7 +182,6 @@ async function handleVercelStream(req, res, rawBody, payload) {
const streamToolCallIDs = new Map();
const streamToolNames = new Map();
const decoder = new TextDecoder();
reader = completionRes.body.getReader();
let buffered = '';
let ended = false;
const { sendFrame, sendDeltaFrame } = createChatCompletionEmitter({
@@ -144,14 +192,14 @@ async function handleVercelStream(req, res, rawBody, payload) {
isClosed: () => clientClosed,
});
const finish = async (reason) => {
const finish = async (reason, options = {}) => {
if (ended) {
return;
return true;
}
ended = true;
if (clientClosed || res.writableEnded || res.destroyed) {
ended = true;
await releaseLease();
return;
return true;
}
const detected = parseStandaloneToolCalls(outputText, toolNames);
if (detected.length > 0 && !toolCallsDoneEmitted) {
@@ -177,21 +225,26 @@ async function handleVercelStream(req, res, rawBody, payload) {
reason = 'tool_calls';
}
if (detected.length === 0 && !toolCallsEmitted && outputText.trim() === '') {
if (options.deferEmpty && reason !== 'content_filter') {
return false;
}
ended = true;
const detail = upstreamEmptyOutputDetail(reason === 'content_filter', outputText, thinkingText);
sendFailedChunk(res, detail.status, detail.message, detail.code);
await releaseLease();
if (!res.writableEnded && !res.destroyed) {
res.end();
}
return;
return true;
}
ended = true;
sendFrame({
id: sessionID,
object: 'chat.completion.chunk',
created,
model,
choices: [{ delta: {}, index: 0, finish_reason: reason }],
usage: buildUsage(finalPrompt, thinkingText, outputText),
usage: buildUsage(usagePrompt, thinkingText, outputText),
});
if (!res.writableEnded && !res.destroyed) {
res.write('data: [DONE]\n\n');
@@ -200,122 +253,194 @@ async function handleVercelStream(req, res, rawBody, payload) {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
return true;
};
try {
const processStream = async (initialResponse, allowDeferEmpty) => {
let currentResponse = initialResponse;
let continueState = createContinueState(sessionID);
let continueRounds = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
if (clientClosed) {
await finish('stop');
return;
}
const { value, done } = await reader.read();
if (done) {
break;
}
buffered += decoder.decode(value, { stream: true });
const lines = buffered.split('\n');
buffered = lines.pop() || '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith('data:')) {
continue;
}
const dataStr = line.slice(5).trim();
if (!dataStr) {
continue;
}
if (dataStr === '[DONE]') {
await finish('stop');
return;
}
let chunk;
try {
chunk = JSON.parse(dataStr);
} catch (_err) {
continue;
}
const parsed = parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenceMarkers);
if (!parsed.parsed) {
continue;
}
currentType = parsed.newType;
if (parsed.errorMessage) {
await finish('content_filter');
return;
}
if (parsed.contentFilter) {
await finish(outputText.trim() === '' ? 'content_filter' : 'stop');
return;
}
if (parsed.finished) {
await finish('stop');
return;
}
for (const p of parsed.parts) {
if (!p.text) {
continue;
reader = currentResponse.body.getReader();
buffered = '';
let streamEnded = false;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
if (clientClosed) {
await finish('stop');
return { terminal: true, retryable: false };
}
if (p.type === 'thinking') {
if (thinkingEnabled) {
const trimmed = trimContinuationOverlap(thinkingText, p.text);
if (!trimmed) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffered += decoder.decode(value, { stream: true });
const lines = buffered.split('\n');
buffered = lines.pop() || '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith('data:')) {
continue;
}
const dataStr = line.slice(5).trim();
if (!dataStr) {
continue;
}
if (dataStr === '[DONE]') {
streamEnded = true;
break;
}
let chunk;
try {
chunk = JSON.parse(dataStr);
} catch (_err) {
continue;
}
observeContinueState(continueState, chunk);
const parsed = parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenceMarkers);
if (!parsed.parsed) {
continue;
}
currentType = parsed.newType;
if (parsed.errorMessage) {
return { terminal: await finish('content_filter'), retryable: false };
}
if (parsed.contentFilter) {
return { terminal: await finish(outputText.trim() === '' ? 'content_filter' : 'stop'), retryable: false };
}
if (parsed.finished) {
streamEnded = true;
break;
}
for (const p of parsed.parts) {
if (!p.text) {
continue;
}
thinkingText += trimmed;
sendDeltaFrame({ reasoning_content: trimmed });
}
} else {
const trimmed = trimContinuationOverlap(outputText, p.text);
if (!trimmed) {
continue;
}
if (searchEnabled && isCitation(trimmed)) {
continue;
}
outputText += trimmed;
if (!toolSieveEnabled) {
sendDeltaFrame({ content: trimmed });
continue;
}
const events = processToolSieveChunk(toolSieveState, trimmed, toolNames);
for (const evt of events) {
if (evt.type === 'tool_call_deltas') {
if (!emitEarlyToolDeltas) {
if (p.type === 'thinking') {
if (thinkingEnabled) {
const trimmed = trimContinuationOverlap(thinkingText, p.text);
if (!trimmed) {
continue;
}
thinkingText += trimmed;
sendDeltaFrame({ reasoning_content: trimmed });
}
} else {
const trimmed = trimContinuationOverlap(outputText, p.text);
if (!trimmed) {
continue;
}
const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames);
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
if (formatted.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatted });
if (searchEnabled && isCitation(trimmed)) {
continue;
}
outputText += trimmed;
if (!toolSieveEnabled) {
sendDeltaFrame({ content: trimmed });
continue;
}
const events = processToolSieveChunk(toolSieveState, trimmed, toolNames);
for (const evt of events) {
if (evt.type === 'tool_call_deltas') {
if (!emitEarlyToolDeltas) {
continue;
}
const filtered = filterIncrementalToolCallDeltasByAllowed(evt.deltas, toolNames, streamToolNames);
const formatted = formatIncrementalToolCallDeltas(filtered, streamToolCallIDs);
if (formatted.length > 0) {
toolCallsEmitted = true;
sendDeltaFrame({ tool_calls: formatted });
}
continue;
}
if (evt.type === 'tool_calls') {
toolCallsEmitted = true;
toolCallsDoneEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}
if (evt.text) {
sendDeltaFrame({ content: evt.text });
}
}
continue;
}
if (evt.type === 'tool_calls') {
toolCallsEmitted = true;
toolCallsDoneEmitted = true;
sendDeltaFrame({ tool_calls: formatOpenAIStreamToolCalls(evt.calls, streamToolCallIDs) });
resetStreamToolCallState(streamToolCallIDs, streamToolNames);
continue;
}
if (evt.text) {
sendDeltaFrame({ content: evt.text });
}
}
if (streamEnded) {
break;
}
}
if (streamEnded) {
break;
}
}
} catch (err) {
if (clientClosed || isAbortError(err)) {
await finish('stop');
return { terminal: true, retryable: false };
}
await finish('stop');
return { terminal: true, retryable: false };
}
if (shouldAutoContinue(continueState) && continueRounds < AUTO_CONTINUE_MAX_ROUNDS) {
continueRounds += 1;
const nextRes = await fetchContinue(continueState.responseMessageID);
if (nextRes === null) {
return { terminal: true, retryable: false };
}
if (!nextRes.ok || !nextRes.body) {
return { terminal: await finish('stop'), retryable: false };
}
continueState = prepareContinueStateForNextRound(continueState);
currentResponse = nextRes;
continue;
}
break;
}
await finish('stop');
} catch (err) {
if (clientClosed || isAbortError(err)) {
const terminal = await finish('stop', { deferEmpty: allowDeferEmpty });
return { terminal, retryable: !terminal && allowDeferEmpty, responseMessageID: continueState.responseMessageID };
};
let retryAttempts = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const processed = await processStream(completionRes, retryAttempts < EMPTY_OUTPUT_RETRY_MAX_ATTEMPTS);
if (processed.terminal) {
return;
}
if (!processed.retryable || retryAttempts >= EMPTY_OUTPUT_RETRY_MAX_ATTEMPTS) {
await finish('stop');
return;
}
retryAttempts += 1;
console.info('[openai_empty_retry] attempting synthetic retry', {
surface: 'chat.completions',
stream: true,
retry_attempt: retryAttempts,
parent_message_id: processed.responseMessageID || 0,
});
usagePrompt = usagePromptWithEmptyOutputRetry(finalPrompt, retryAttempts);
const retryPowHeader = await refreshPowHeader('retry');
if (!retryPowHeader) {
return;
}
completionRes = await fetchDeepSeekStream(
DEEPSEEK_COMPLETION_URL,
clonePayloadForEmptyOutputRetry(completionPayload, processed.responseMessageID),
retryPowHeader,
);
if (completionRes === null) {
return;
}
if (!completionRes.ok || !completionRes.body) {
await finish('stop');
return;
}
await finish('stop');
}
} finally {
req.removeListener('aborted', onReqAborted);
@@ -328,6 +453,113 @@ function toBool(v) {
return v === true;
}
function clonePayloadForEmptyOutputRetry(payload, parentMessageID) {
const clone = {
...(payload || {}),
prompt: appendEmptyOutputRetrySuffix(asString(payload && payload.prompt)),
};
if (parentMessageID && parentMessageID > 0) {
clone.parent_message_id = parentMessageID;
}
return clone;
}
function appendEmptyOutputRetrySuffix(prompt) {
const base = asString(prompt).trimEnd();
if (!base) {
return EMPTY_OUTPUT_RETRY_SUFFIX;
}
return `${base}\n\n${EMPTY_OUTPUT_RETRY_SUFFIX}`;
}
function usagePromptWithEmptyOutputRetry(originalPrompt, attempts) {
if (!attempts || attempts <= 0) {
return originalPrompt;
}
const parts = [originalPrompt];
let next = originalPrompt;
for (let i = 0; i < attempts; i += 1) {
next = appendEmptyOutputRetrySuffix(next);
parts.push(next);
}
return parts.join('\n');
}
function createContinueState(sessionID) {
return {
sessionID: asString(sessionID),
responseMessageID: 0,
lastStatus: '',
finished: false,
};
}
function prepareContinueStateForNextRound(state) {
return {
...state,
lastStatus: '',
finished: false,
};
}
function observeContinueState(state, chunk) {
if (!state || !chunk || typeof chunk !== 'object') {
return;
}
const topID = numberValue(chunk.response_message_id);
if (topID > 0) {
state.responseMessageID = topID;
}
if (chunk.p === 'response/status') {
setContinueStatus(state, asString(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);
if (id > 0) {
state.responseMessageID = id;
}
setContinueStatus(state, asString(response.status));
if (response.auto_continue === true) {
state.lastStatus = 'AUTO_CONTINUE';
}
}
const messageResponse = chunk.message && typeof chunk.message === 'object' && chunk.message.response;
if (messageResponse && typeof messageResponse === 'object') {
const id = numberValue(messageResponse.message_id);
if (id > 0) {
state.responseMessageID = id;
}
setContinueStatus(state, asString(messageResponse.status));
}
}
function setContinueStatus(state, status) {
const normalized = asString(status).trim();
if (!normalized) {
return;
}
state.lastStatus = normalized;
if (normalized.toUpperCase() === 'FINISHED') {
state.finished = true;
}
}
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());
}
function numberValue(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return Math.trunc(v);
}
const parsed = Number.parseInt(asString(v), 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function upstreamEmptyOutputDetail(contentFilter, _text, thinking) {
if (contentFilter) {
return {

View File

@@ -6,10 +6,10 @@ const {
const {
parseMarkupToolCalls,
stripFencedCodeBlocks,
containsToolCallWrapperSyntaxOutsideIgnored,
sanitizeLooseCDATA,
} = require('./parse_payload');
const TOOL_MARKUP_PREFIXES = ['<tool_calls'];
function extractToolNames(tools) {
if (!Array.isArray(tools) || tools.length === 0) {
return [];
@@ -46,7 +46,13 @@ function parseToolCallsDetailed(text, toolNames) {
return result;
}
// XML markup parsing only.
const parsed = parseMarkupToolCalls(normalized);
let parsed = parseMarkupToolCalls(normalized);
if (parsed.length === 0 && normalized.toLowerCase().includes('<![cdata[')) {
const recovered = sanitizeLooseCDATA(normalized);
if (recovered !== normalized) {
parsed = parseMarkupToolCalls(recovered);
}
}
if (parsed.length === 0) {
return result;
}
@@ -73,7 +79,13 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
return result;
}
// XML markup parsing only.
const parsed = parseMarkupToolCalls(trimmed);
let parsed = parseMarkupToolCalls(trimmed);
if (parsed.length === 0 && trimmed.toLowerCase().includes('<![cdata[')) {
const recovered = sanitizeLooseCDATA(trimmed);
if (recovered !== trimmed) {
parsed = parseMarkupToolCalls(recovered);
}
}
if (parsed.length === 0) {
return result;
}
@@ -110,8 +122,8 @@ function filterToolCallsDetailed(parsed, toolNames) {
}
function looksLikeToolCallSyntax(text) {
const lower = toStringSafe(text).toLowerCase();
return TOOL_MARKUP_PREFIXES.some((prefix) => lower.includes(prefix));
const styles = containsToolCallWrapperSyntaxOutsideIgnored(text);
return styles.dsml || styles.canonical;
}
function shouldSkipToolCallParsingForCodeFenceExample(text) {

View File

@@ -1,8 +1,8 @@
'use strict';
const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi;
const CDATA_PATTERN = /^<!\[CDATA\[([\s\S]*?)]]>$/i;
const XML_ATTR_PATTERN = /\b([a-z0-9_:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gi;
const TOOL_MARKUP_NAMES = ['tool_calls', 'invoke', 'parameter'];
const {
toStringSafe,
@@ -13,11 +13,110 @@ function stripFencedCodeBlocks(text) {
if (!t) {
return '';
}
return t.replace(/```[\s\S]*?```/g, ' ');
const lines = t.split('\n');
const out = [];
let inFence = false;
let fenceChar = '';
let fenceLen = 0;
let inCDATA = false;
let beforeFenceIdx = 0;
for (let li = 0; li < lines.length; li += 1) {
const line = lines[li];
const lineWithNL = li < lines.length - 1 ? line + '\n' : line;
// CDATA protection
if (inCDATA || cdataStartsBeforeFence(line)) {
out.push(lineWithNL);
inCDATA = updateCDATAStateLine(inCDATA, line);
continue;
}
const trimmed = line.replace(/^[ \t]+/, '');
if (!inFence) {
const fence = parseFenceOpenLine(trimmed);
if (fence) {
inFence = true;
fenceChar = fence.ch;
fenceLen = fence.count;
beforeFenceIdx = out.length;
continue;
}
out.push(lineWithNL);
continue;
}
if (isFenceCloseLine(trimmed, fenceChar, fenceLen)) {
inFence = false;
fenceChar = '';
fenceLen = 0;
}
}
if (inFence) {
// Unclosed fence: keep content before the fence started.
if (beforeFenceIdx > 0) {
return out.slice(0, beforeFenceIdx).join('');
}
return '';
}
return out.join('');
}
function parseFenceOpenLine(trimmed) {
if (trimmed.length < 3) return null;
const ch = trimmed[0];
if (ch !== '`' && ch !== '~') return null;
let count = 0;
while (count < trimmed.length && trimmed[count] === ch) count++;
if (count < 3) return null;
return { ch, count };
}
function isFenceCloseLine(trimmed, fenceChar, fenceLen) {
if (!fenceChar || !trimmed || trimmed[0] !== fenceChar) return false;
let count = 0;
while (count < trimmed.length && trimmed[count] === fenceChar) count++;
if (count < fenceLen) return false;
return trimmed.slice(count).trim() === '';
}
function cdataStartsBeforeFence(line) {
const cdataIdx = line.toLowerCase().indexOf('<![cdata[');
if (cdataIdx < 0) return false;
const fenceIdx = Math.min(
line.indexOf('```') >= 0 ? line.indexOf('```') : Infinity,
line.indexOf('~~~') >= 0 ? line.indexOf('~~~') : Infinity,
);
return fenceIdx === Infinity || cdataIdx < fenceIdx;
}
function updateCDATAStateLine(inCDATA, line) {
const lower = line.toLowerCase();
let pos = 0;
let state = inCDATA;
while (pos < lower.length) {
if (state) {
const end = lower.indexOf(']]>', pos);
if (end < 0) return true;
pos = end + ']]>'.length;
state = false;
continue;
}
const start = lower.indexOf('<![cdata[', pos);
if (start < 0) return false;
pos = start + '<![cdata['.length;
state = true;
}
return state;
}
function parseMarkupToolCalls(text) {
const raw = toStringSafe(text).trim();
const normalized = normalizeDSMLToolCallMarkup(toStringSafe(text));
if (!normalized.ok) {
return [];
}
const raw = normalized.text.trim();
if (!raw) {
return [];
}
@@ -34,6 +133,133 @@ function parseMarkupToolCalls(text) {
return out;
}
function normalizeDSMLToolCallMarkup(text) {
const raw = toStringSafe(text);
if (!raw) {
return { text: '', ok: true };
}
const styles = containsToolMarkupSyntaxOutsideIgnored(raw);
if (!styles.dsml) {
return { text: raw, ok: true };
}
return {
text: replaceDSMLToolMarkupOutsideIgnored(raw),
ok: true,
};
}
function containsDSMLToolMarkup(text) {
return containsToolMarkupSyntaxOutsideIgnored(text).dsml;
}
function containsCanonicalToolMarkup(text) {
return containsToolMarkupSyntaxOutsideIgnored(text).canonical;
}
function containsToolCallWrapperSyntaxOutsideIgnored(text) {
const raw = toStringSafe(text);
const styles = { dsml: false, canonical: false };
if (!raw) {
return styles;
}
const lower = raw.toLowerCase();
for (let i = 0; i < raw.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
return styles;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
const tag = scanToolMarkupTagAt(raw, i);
if (tag) {
if (tag.name !== 'tool_calls') {
i = tag.end + 1;
continue;
}
if (tag.dsmlLike) {
styles.dsml = true;
} else {
styles.canonical = true;
}
if (styles.dsml && styles.canonical) {
return styles;
}
i = tag.end + 1;
continue;
}
i += 1;
}
return styles;
}
function containsToolMarkupSyntaxOutsideIgnored(text) {
const raw = toStringSafe(text);
const styles = { dsml: false, canonical: false };
if (!raw) {
return styles;
}
for (let i = 0; i < raw.length;) {
const skipped = skipXmlIgnoredSection(raw.toLowerCase(), i);
if (skipped.blocked) {
return styles;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
const tag = scanToolMarkupTagAt(raw, i);
if (tag) {
if (tag.dsmlLike) {
styles.dsml = true;
} else {
styles.canonical = true;
}
if (styles.dsml && styles.canonical) {
return styles;
}
i = tag.end + 1;
continue;
}
i += 1;
}
return styles;
}
function replaceDSMLToolMarkupOutsideIgnored(text) {
const raw = toStringSafe(text);
if (!raw) {
return '';
}
const lower = raw.toLowerCase();
let out = '';
for (let i = 0; i < raw.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
out += raw.slice(i);
break;
}
if (skipped.advanced) {
out += raw.slice(i, skipped.next);
i = skipped.next;
continue;
}
const tag = scanToolMarkupTagAt(raw, i);
if (tag) {
if (tag.dsmlLike) {
out += `<${tag.closing ? '/' : ''}${tag.name}${raw.slice(tag.nameEnd, tag.end + 1)}`;
} else {
out += raw.slice(tag.start, tag.end + 1);
}
i = tag.end + 1;
continue;
}
out += raw[i];
i += 1;
}
return out;
}
function parseMarkupSingleToolCall(block) {
const attrs = parseTagAttributes(block.attrs);
const name = toStringSafe(attrs.name).trim();
@@ -66,7 +292,7 @@ function parseMarkupSingleToolCall(block) {
if (!paramName) {
continue;
}
appendMarkupValue(input, paramName, parseMarkupValue(match.body));
appendMarkupValue(input, paramName, parseMarkupValue(match.body, paramName));
}
if (Object.keys(input).length === 0 && inner.trim() !== '') {
return null;
@@ -89,7 +315,8 @@ function findXmlElementBlocks(text, tag) {
}
const end = findMatchingXmlEndTagOutsideCDATA(source, name, start.bodyStart);
if (!end) {
break;
pos = start.bodyStart;
continue;
}
out.push({
attrs: start.attrs,
@@ -190,6 +417,150 @@ function skipXmlIgnoredSection(lower, i) {
return { advanced: false, blocked: false, next: i };
}
function scanToolMarkupTagAt(text, start) {
const raw = toStringSafe(text);
if (!raw || start < 0 || start >= raw.length || raw[start] !== '<') {
return null;
}
const lower = raw.toLowerCase();
let i = start + 1;
const closing = raw[i] === '/';
if (closing) {
i += 1;
}
let dsmlLike = false;
if (i < raw.length && isToolMarkupPipe(raw[i])) {
dsmlLike = true;
i += 1;
}
if (lower.startsWith('dsml', i)) {
dsmlLike = true;
i += 'dsml'.length;
while (i < raw.length && isToolMarkupSeparator(raw[i])) {
i += 1;
}
}
const { name, len } = matchToolMarkupName(lower, i);
if (!name) {
return null;
}
const nameEnd = i + len;
if (!hasXmlTagBoundary(raw, nameEnd)) {
return null;
}
const end = findXmlTagEnd(raw, nameEnd);
if (end < 0) {
return null;
}
return {
start,
end,
nameStart: i,
nameEnd,
name,
closing,
selfClosing: raw.slice(start, end + 1).trim().endsWith('/>'),
dsmlLike,
canonical: !dsmlLike,
};
}
function findToolMarkupTagOutsideIgnored(text, from) {
const raw = toStringSafe(text);
const lower = raw.toLowerCase();
for (let i = Math.max(0, from || 0); i < raw.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
return null;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
const tag = scanToolMarkupTagAt(raw, i);
if (tag) {
return tag;
}
i += 1;
}
return null;
}
function findMatchingToolMarkupClose(text, openTag) {
const raw = toStringSafe(text);
if (!raw || !openTag || !openTag.name || openTag.closing) {
return null;
}
let depth = 1;
for (let pos = openTag.end + 1; pos < raw.length;) {
const tag = findToolMarkupTagOutsideIgnored(raw, pos);
if (!tag) {
return null;
}
if (tag.name !== openTag.name) {
pos = tag.end + 1;
continue;
}
if (tag.closing) {
depth -= 1;
if (depth === 0) {
return tag;
}
} else if (!tag.selfClosing) {
depth += 1;
}
pos = tag.end + 1;
}
return null;
}
function findPartialToolMarkupStart(text) {
const raw = toStringSafe(text);
const lastLT = raw.lastIndexOf('<');
if (lastLT < 0) {
return -1;
}
const tail = raw.slice(lastLT);
if (tail.includes('>')) {
return -1;
}
const lowerTail = tail.toLowerCase();
const candidates = [
'<tool_calls', '<invoke', '<parameter',
'<|tool_calls', '<|invoke', '<|parameter',
'<tool_calls', '<invoke', '<parameter',
'<|dsml|tool_calls', '<|dsml|invoke', '<|dsml|parameter',
'<dsmltool_calls', '<dsmlinvoke', '<dsmlparameter',
'<dsml tool_calls', '<dsml invoke', '<dsml parameter',
'<dsml|tool_calls', '<dsml|invoke', '<dsml|parameter',
'<|dsmltool_calls', '<|dsmlinvoke', '<|dsmlparameter',
'<|dsml tool_calls', '<|dsml invoke', '<|dsml parameter',
];
for (const candidate of candidates) {
if (candidate.startsWith(lowerTail)) {
return lastLT;
}
}
return -1;
}
function isToolMarkupPipe(ch) {
return ch === '|' || ch === '';
}
function isToolMarkupSeparator(ch) {
return ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n' || isToolMarkupPipe(ch);
}
function matchToolMarkupName(lower, start) {
for (const name of TOOL_MARKUP_NAMES) {
if (lower.startsWith(name, start)) {
return { name, len: name.length };
}
}
return { name: '', len: 0 };
}
function findXmlTagEnd(text, from) {
let quote = '';
for (let i = Math.max(0, from || 0); i < text.length; i += 1) {
@@ -228,8 +599,11 @@ function parseMarkupInput(raw) {
return {};
}
// Prioritize XML-style KV tags (e.g., <arg>val</arg>)
const kv = parseMarkupKVObject(s);
if (Object.keys(kv).length > 0) {
const kv = unwrapItemOnlyMarkupValue(parseMarkupKVObject(s));
if (Array.isArray(kv)) {
return kv;
}
if (kv && typeof kv === 'object' && Object.keys(kv).length > 0) {
return kv;
}
@@ -250,12 +624,12 @@ function parseMarkupKVObject(text) {
return {};
}
const out = {};
for (const m of raw.matchAll(TOOL_CALL_MARKUP_KV_PATTERN)) {
const key = toStringSafe(m[1]).trim();
for (const block of findGenericXmlElementBlocks(raw)) {
const key = toStringSafe(block.localName).trim();
if (!key) {
continue;
}
const value = parseMarkupValue(m[2]);
const value = parseMarkupValue(block.body, key);
if (value === undefined || value === null) {
continue;
}
@@ -264,10 +638,146 @@ function parseMarkupKVObject(text) {
return out;
}
function parseMarkupValue(raw) {
function findGenericXmlElementBlocks(text) {
const source = toStringSafe(text);
if (!source) {
return [];
}
const out = [];
let pos = 0;
while (pos < source.length) {
const start = findGenericXmlStartTagOutsideCDATA(source, pos);
if (!start) {
break;
}
if (start.selfClosing) {
out.push({
name: start.name,
localName: start.localName,
attrs: start.attrs,
body: '',
start: start.start,
end: start.end + 1,
});
pos = start.end + 1;
continue;
}
const end = findMatchingGenericXmlEndTagOutsideCDATA(source, start.name, start.bodyStart);
if (!end) {
pos = start.bodyStart;
continue;
}
out.push({
name: start.name,
localName: start.localName,
attrs: start.attrs,
body: source.slice(start.bodyStart, end.closeStart),
start: start.start,
end: end.closeEnd,
});
pos = end.closeEnd;
}
return out;
}
function findGenericXmlStartTagOutsideCDATA(text, from) {
const lower = text.toLowerCase();
for (let i = Math.max(0, from || 0); i < text.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
return null;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
if (text[i] !== '<' || text[i + 1] === '/' || text[i + 1] === '!' || text[i + 1] === '?') {
i += 1;
continue;
}
const match = text.slice(i + 1).match(/^([A-Za-z_][A-Za-z0-9_.:-]*)/);
if (!match) {
i += 1;
continue;
}
const name = match[1];
const nameEnd = i + 1 + name.length;
if (!hasXmlTagBoundary(text, nameEnd)) {
i += 1;
continue;
}
const tagEnd = findXmlTagEnd(text, nameEnd);
if (tagEnd < 0) {
return null;
}
return {
start: i,
end: tagEnd,
bodyStart: tagEnd + 1,
name,
localName: name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name,
attrs: text.slice(nameEnd, tagEnd),
selfClosing: isSelfClosingXmlTag(text.slice(i, tagEnd)),
};
}
return null;
}
function findMatchingGenericXmlEndTagOutsideCDATA(text, name, from) {
const lower = text.toLowerCase();
const needle = toStringSafe(name).toLowerCase();
if (!needle) {
return null;
}
const openTarget = `<${needle}`;
const closeTarget = `</${needle}`;
let depth = 1;
for (let i = Math.max(0, from || 0); i < text.length;) {
const skipped = skipXmlIgnoredSection(lower, i);
if (skipped.blocked) {
return null;
}
if (skipped.advanced) {
i = skipped.next;
continue;
}
if (lower.startsWith(closeTarget, i) && hasXmlTagBoundary(text, i + closeTarget.length)) {
const tagEnd = findXmlTagEnd(text, i + closeTarget.length);
if (tagEnd < 0) {
return null;
}
depth -= 1;
if (depth === 0) {
return { closeStart: i, closeEnd: tagEnd + 1 };
}
i = tagEnd + 1;
continue;
}
if (lower.startsWith(openTarget, i) && hasXmlTagBoundary(text, i + openTarget.length)) {
const tagEnd = findXmlTagEnd(text, i + openTarget.length);
if (tagEnd < 0) {
return null;
}
if (!isSelfClosingXmlTag(text.slice(i, tagEnd))) {
depth += 1;
}
i = tagEnd + 1;
continue;
}
i += 1;
}
return null;
}
function parseMarkupValue(raw, paramName = '') {
const cdata = extractStandaloneCDATA(raw);
if (cdata.ok) {
return cdata.value;
const literal = parseJSONLiteralValue(cdata.value);
if (literal.ok) {
return literal.value;
}
const structured = parseStructuredCDATAParameterValue(paramName, cdata.value);
return structured.ok ? structured.value : cdata.value;
}
const s = toStringSafe(extractRawTagValue(raw)).trim();
if (!s) {
@@ -275,8 +785,11 @@ function parseMarkupValue(raw) {
}
if (s.includes('<') && s.includes('>')) {
const nested = parseMarkupInput(s);
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
const nested = unwrapItemOnlyMarkupValue(parseMarkupInput(s));
if (Array.isArray(nested)) {
return nested;
}
if (nested && typeof nested === 'object') {
if (isOnlyRawValue(nested)) {
return toStringSafe(nested._raw);
}
@@ -284,16 +797,73 @@ function parseMarkupValue(raw) {
}
}
if (s.startsWith('{') || s.startsWith('[')) {
try {
return JSON.parse(s);
} catch (_err) {
return s;
}
const literal = parseJSONLiteralValue(s);
if (literal.ok) {
return literal.value;
}
return s;
}
function parseStructuredCDATAParameterValue(paramName, raw) {
if (preservesCDATAStringParameter(paramName)) {
return { ok: false, value: null };
}
const normalized = normalizeCDATAForStructuredParse(raw);
if (!normalized.includes('<') || !normalized.includes('>')) {
return { ok: false, value: null };
}
const parsed = parseMarkupInput(normalized);
if (Array.isArray(parsed)) {
return { ok: true, value: parsed };
}
if (parsed && typeof parsed === 'object' && !isOnlyRawValue(parsed) && Object.keys(parsed).length > 0) {
return { ok: true, value: parsed };
}
return { ok: false, value: null };
}
function normalizeCDATAForStructuredParse(raw) {
return unescapeHtml(toStringSafe(raw).replace(/<br\s*\/?>/gi, '\n').trim());
}
function preservesCDATAStringParameter(name) {
return new Set([
'content',
'file_content',
'text',
'prompt',
'query',
'command',
'cmd',
'script',
'code',
'old_string',
'new_string',
'pattern',
'path',
'file_path',
]).has(toStringSafe(name).trim().toLowerCase());
}
function unwrapItemOnlyMarkupValue(value) {
if (Array.isArray(value)) {
return value.map(unwrapItemOnlyMarkupValue);
}
if (!value || typeof value !== 'object') {
return value;
}
const keys = Object.keys(value);
if (keys.length === 1 && keys[0] === 'item') {
const items = unwrapItemOnlyMarkupValue(value.item);
return Array.isArray(items) ? items : [items];
}
const out = {};
for (const key of keys) {
out[key] = unwrapItemOnlyMarkupValue(value[key]);
}
return out;
}
function extractRawTagValue(inner) {
const s = toStringSafe(inner).trim();
if (!s) {
@@ -327,9 +897,65 @@ function extractStandaloneCDATA(inner) {
if (cdataMatch && cdataMatch[1] !== undefined) {
return { ok: true, value: cdataMatch[1] };
}
if (s.toLowerCase().startsWith('<![cdata[')) {
return { ok: true, value: s.slice('<![CDATA['.length) };
}
return { ok: false, value: '' };
}
function parseJSONLiteralValue(raw) {
const s = toStringSafe(raw).trim();
if (!s) {
return { ok: false, value: null };
}
if (!['{', '[', '"', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 't', 'f', 'n'].includes(s[0])) {
return { ok: false, value: null };
}
try {
return { ok: true, value: JSON.parse(s) };
} catch (_err) {
return { ok: false, value: null };
}
}
function sanitizeLooseCDATA(text) {
const raw = toStringSafe(text);
if (!raw) {
return '';
}
const lower = raw.toLowerCase();
const openMarker = '<![cdata[';
const closeMarker = ']]>';
let out = '';
let pos = 0;
let changed = false;
while (pos < raw.length) {
const startRel = lower.indexOf(openMarker, pos);
if (startRel < 0) {
out += raw.slice(pos);
break;
}
const start = startRel;
const contentStart = start + openMarker.length;
out += raw.slice(pos, start);
const endRel = lower.indexOf(closeMarker, contentStart);
if (endRel >= 0) {
const end = endRel + closeMarker.length;
out += raw.slice(start, end);
pos = end;
continue;
}
changed = true;
out += raw.slice(contentStart);
pos = raw.length;
}
return changed ? out : raw;
}
function parseTagAttributes(raw) {
const source = toStringSafe(raw);
const out = {};
@@ -403,4 +1029,11 @@ function isOnlyRawValue(obj) {
module.exports = {
stripFencedCodeBlocks,
parseMarkupToolCalls,
normalizeDSMLToolCallMarkup,
containsToolMarkupSyntaxOutsideIgnored,
containsToolCallWrapperSyntaxOutsideIgnored,
findToolMarkupTagOutsideIgnored,
findMatchingToolMarkupClose,
findPartialToolMarkupStart,
sanitizeLooseCDATA,
};

View File

@@ -1,115 +1,121 @@
'use strict';
const { parseToolCalls } = require('./parse');
// XML wrapper tag pair used by the streaming sieve.
const XML_TOOL_TAG_PAIRS = [
{ open: '<tool_calls', close: '</tool_calls>' },
];
const XML_TOOL_OPENING_TAGS = XML_TOOL_TAG_PAIRS.map(p => p.open);
const {
findToolMarkupTagOutsideIgnored,
findMatchingToolMarkupClose,
findPartialToolMarkupStart,
} = require('./parse_payload');
function consumeXMLToolCapture(captured, toolNames, trimWrappingJSONFence) {
const lower = captured.toLowerCase();
// Find the FIRST matching open/close pair for the canonical wrapper.
for (const pair of XML_TOOL_TAG_PAIRS) {
const openIdx = lower.indexOf(pair.open);
if (openIdx < 0) {
let anyOpenFound = false;
let best = null;
let rejected = null;
// Scan every recognized wrapper occurrence. Prose can mention a wrapper tag
// before the actual tool block, including the same variant as the real block.
for (let searchFrom = 0; searchFrom < captured.length;) {
const openTag = findFirstToolTag(captured, searchFrom, 'tool_calls', false);
if (!openTag) {
break;
}
const closeTag = findMatchingToolMarkupClose(captured, openTag);
if (!closeTag) {
anyOpenFound = true;
searchFrom = openTag.end + 1;
continue;
}
// Ignore closing tags that appear inside CDATA payloads, such as
// write-file content containing tool-call documentation examples.
const closeIdx = findXMLCloseOutsideCDATA(captured, pair.close, openIdx + pair.open.length);
if (closeIdx < 0) {
// Opening tag present but specific closing tag hasn't arrived.
// Return not-ready so buffering continues until the wrapper closes.
return { ready: false, prefix: '', calls: [], suffix: '' };
}
const closeEnd = closeIdx + pair.close.length;
const xmlBlock = captured.slice(openIdx, closeEnd);
let prefixPart = captured.slice(0, openIdx);
let suffixPart = captured.slice(closeEnd);
const xmlBlock = captured.slice(openTag.start, closeTag.end + 1);
const prefixPart = captured.slice(0, openTag.start);
const suffixPart = captured.slice(closeTag.end + 1);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: trimmedFence.prefix,
calls: parsed,
suffix: trimmedFence.suffix,
if (!best || openTag.start < best.start) {
best = {
start: openTag.start,
prefix: trimmedFence.prefix,
calls: parsed,
suffix: trimmedFence.suffix,
};
}
break;
}
if (!rejected || openTag.start < rejected.start) {
rejected = {
start: openTag.start,
prefix: prefixPart + xmlBlock,
suffix: suffixPart,
};
}
searchFrom = openTag.end + 1;
}
if (best) {
return { ready: true, prefix: best.prefix, calls: best.calls, suffix: best.suffix };
}
if (anyOpenFound) {
// At least one opening tag was found but none had a matching close tag.
return { ready: false, prefix: '', calls: [], suffix: '' };
}
if (rejected) {
// If this block failed to become a tool call, pass it through as text.
return { ready: true, prefix: prefixPart + xmlBlock, calls: [], suffix: suffixPart };
return { ready: true, prefix: rejected.prefix, calls: [], suffix: rejected.suffix };
}
const invokeTag = findFirstToolTag(captured, 0, 'invoke', false);
if (invokeTag) {
const wrapperOpen = findFirstToolTag(captured, 0, 'tool_calls', false);
if (!wrapperOpen || wrapperOpen.start > invokeTag.start) {
const closeTag = findFirstToolTag(captured, invokeTag.start + 1, 'tool_calls', true);
if (closeTag && closeTag.start > invokeTag.start) {
const xmlBlock = '<tool_calls>' + captured.slice(invokeTag.start, closeTag.end + 1);
const prefixPart = captured.slice(0, invokeTag.start);
const suffixPart = captured.slice(closeTag.end + 1);
const parsed = parseToolCalls(xmlBlock, toolNames);
if (Array.isArray(parsed) && parsed.length > 0) {
const trimmedFence = trimWrappingJSONFence(prefixPart, suffixPart);
return {
ready: true,
prefix: trimmedFence.prefix,
calls: parsed,
suffix: trimmedFence.suffix,
};
}
return { ready: true, prefix: prefixPart + captured.slice(invokeTag.start, closeTag.end + 1), calls: [], suffix: suffixPart };
}
}
}
return { ready: false, prefix: '', calls: [], suffix: '' };
}
function hasOpenXMLToolTag(captured) {
const lower = captured.toLowerCase();
for (const pair of XML_TOOL_TAG_PAIRS) {
const openIdx = lower.indexOf(pair.open);
if (openIdx >= 0) {
if (findXMLCloseOutsideCDATA(captured, pair.close, openIdx + pair.open.length) < 0) {
return true;
}
for (let pos = 0; pos < captured.length;) {
const tag = findFirstToolTag(captured, pos, 'tool_calls', false);
if (!tag) {
return false;
}
if (!findMatchingToolMarkupClose(captured, tag)) {
return true;
}
pos = tag.end + 1;
}
return false;
}
function findPartialXMLToolTagStart(s) {
const lastLT = s.lastIndexOf('<');
if (lastLT < 0) {
return -1;
}
const tail = s.slice(lastLT);
if (tail.includes('>')) {
return -1;
}
const lowerTail = tail.toLowerCase();
for (const tag of XML_TOOL_OPENING_TAGS) {
const tagWithLT = tag.startsWith('<') ? tag : '<' + tag;
if (tagWithLT.startsWith(lowerTail)) {
return lastLT;
function findFirstToolTag(text, from, name, closing) {
for (let pos = Math.max(0, from || 0); pos < text.length;) {
const tag = findToolMarkupTagOutsideIgnored(text, pos);
if (!tag) {
return null;
}
}
return -1;
}
function findXMLCloseOutsideCDATA(s, closeTag, start) {
const text = typeof s === 'string' ? s : '';
const target = String(closeTag || '').toLowerCase();
if (!text || !target) {
return -1;
}
const lower = text.toLowerCase();
for (let i = Math.max(0, start || 0); i < text.length;) {
if (lower.startsWith('<![cdata[', i)) {
const end = lower.indexOf(']]>', i + '<![cdata['.length);
if (end < 0) {
return -1;
}
i = end + ']]>'.length;
continue;
if (tag.name === name && tag.closing === closing) {
return tag;
}
if (lower.startsWith('<!--', i)) {
const end = lower.indexOf('-->', i + '<!--'.length);
if (end < 0) {
return -1;
}
i = end + '-->'.length;
continue;
}
if (lower.startsWith(target, i)) {
return i;
}
i += 1;
pos = tag.end + 1;
}
return -1;
return null;
}
module.exports = {
consumeXMLToolCapture,
hasOpenXMLToolTag,
findPartialXMLToolTagStart,
findPartialXMLToolTagStart: findPartialToolMarkupStart,
};

View File

@@ -6,8 +6,9 @@ const {
} = require('./state');
const { trimWrappingJSONFence } = require('./jsonscan');
const {
XML_TOOL_SEGMENT_TAGS,
} = require('./tool-keywords');
findToolMarkupTagOutsideIgnored,
sanitizeLooseCDATA,
} = require('./parse_payload');
const {
consumeXMLToolCapture: consumeXMLToolCaptureImpl,
hasOpenXMLToolTag,
@@ -43,6 +44,10 @@ function processToolSieveChunk(state, chunk, toolNames) {
resetIncrementalToolState(state);
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
if (consumed.prefix) {
noteText(state, consumed.prefix);
events.push({ type: 'text', text: consumed.prefix });
}
state.pendingToolRaw = captured;
state.pendingToolCalls = consumed.calls;
if (consumed.suffix) {
@@ -113,8 +118,27 @@ function flushToolSieve(state, toolNames) {
}
} else if (state.capture) {
const content = state.capture;
noteText(state, content);
events.push({ type: 'text', text: content });
const recovered = sanitizeLooseCDATA(content);
if (recovered !== content) {
const recoveredResult = consumeXMLToolCaptureImpl(recovered, toolNames, trimWrappingJSONFence);
if (recoveredResult.ready && Array.isArray(recoveredResult.calls) && recoveredResult.calls.length > 0) {
if (recoveredResult.prefix) {
noteText(state, recoveredResult.prefix);
events.push({ type: 'text', text: recoveredResult.prefix });
}
events.push({ type: 'tool_calls', calls: recoveredResult.calls });
if (recoveredResult.suffix) {
noteText(state, recoveredResult.suffix);
events.push({ type: 'text', text: recoveredResult.suffix });
}
} else {
noteText(state, content);
events.push({ type: 'text', text: content });
}
} else {
noteText(state, content);
events.push({ type: 'text', text: content });
}
}
state.capture = '';
state.capturing = false;
@@ -151,26 +175,16 @@ function findToolSegmentStart(state, s) {
if (!s) {
return -1;
}
const lower = s.toLowerCase();
let offset = 0;
while (true) {
// Only check XML tool tags.
let bestIdx = -1;
let matchedTag = '';
for (const tag of XML_TOOL_SEGMENT_TAGS) {
const idx = lower.indexOf(tag, offset);
if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) {
bestIdx = idx;
matchedTag = tag;
}
}
if (bestIdx < 0) {
const tag = findToolMarkupTagOutsideIgnored(s, offset);
if (!tag) {
return -1;
}
if (!insideCodeFenceWithState(state, s.slice(0, bestIdx))) {
return bestIdx;
if (!insideCodeFenceWithState(state, s.slice(0, tag.start))) {
return tag.start;
}
offset = bestIdx + matchedTag.length;
offset = tag.end + 1;
}
}

View File

@@ -7,6 +7,7 @@ function createToolSieveState() {
capturing: false,
codeFenceStack: [],
codeFencePendingTicks: 0,
codeFencePendingTildes: 0,
codeFenceLineStart: true,
pendingToolRaw: '',
pendingToolCalls: [],
@@ -46,8 +47,7 @@ function insideCodeFence(text) {
if (!t) {
return false;
}
const ticks = (t.match(/```/g) || []).length;
return ticks % 2 === 1;
return simulateCodeFenceState([], 0, 0, true, t).stack.length > 0;
}
function insideCodeFenceWithState(state, text) {
@@ -57,6 +57,7 @@ function insideCodeFenceWithState(state, text) {
const simulated = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
Number.isInteger(state.codeFencePendingTildes) ? state.codeFencePendingTildes : 0,
state.codeFenceLineStart !== false,
text,
);
@@ -70,37 +71,57 @@ function updateCodeFenceState(state, text) {
const next = simulateCodeFenceState(
Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [],
Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0,
Number.isInteger(state.codeFencePendingTildes) ? state.codeFencePendingTildes : 0,
state.codeFenceLineStart !== false,
text,
);
state.codeFenceStack = next.stack;
state.codeFencePendingTicks = next.pendingTicks;
state.codeFencePendingTildes = next.pendingTildes;
state.codeFenceLineStart = next.lineStart;
}
function simulateCodeFenceState(stack, pendingTicks, lineStart, text) {
function simulateCodeFenceState(stack, pendingTicks, pendingTildes, lineStart, text) {
const chunk = typeof text === 'string' ? text : '';
const nextStack = Array.isArray(stack) ? [...stack] : [];
let ticks = Number.isInteger(pendingTicks) ? pendingTicks : 0;
let tildes = Number.isInteger(pendingTildes) ? pendingTildes : 0;
let atLineStart = lineStart !== false;
const flushTicks = () => {
const flushPending = () => {
if (ticks > 0) {
if (atLineStart && ticks >= 3) {
applyFenceMarker(nextStack, ticks);
applyFenceMarker(nextStack, ticks); // positive = backtick
}
atLineStart = false;
ticks = 0;
}
if (tildes > 0) {
if (atLineStart && tildes >= 3) {
applyFenceMarker(nextStack, -tildes); // negative = tilde
}
atLineStart = false;
tildes = 0;
}
};
for (let i = 0; i < chunk.length; i += 1) {
const ch = chunk[i];
if (ch === '`') {
if (tildes > 0) {
flushPending();
}
ticks += 1;
continue;
}
flushTicks();
if (ch === '~') {
if (ticks > 0) {
flushPending();
}
tildes += 1;
continue;
}
flushPending();
if (ch === '\n' || ch === '\r') {
atLineStart = true;
continue;
@@ -110,29 +131,37 @@ function simulateCodeFenceState(stack, pendingTicks, lineStart, text) {
}
atLineStart = false;
}
// keep ticks for cross-chunk continuation.
return {
stack: nextStack,
pendingTicks: ticks,
pendingTildes: tildes,
lineStart: atLineStart,
};
}
function applyFenceMarker(stack, ticks) {
// Positive values = backtick fences, negative = tilde fences.
// Closing must match fence type.
function applyFenceMarker(stack, marker) {
if (!Array.isArray(stack)) {
return;
}
if (stack.length === 0) {
stack.push(ticks);
stack.push(marker);
return;
}
const top = stack[stack.length - 1];
if (ticks >= top) {
const sameType = (top > 0 && marker > 0) || (top < 0 && marker < 0);
if (!sameType) {
stack.push(marker);
return;
}
const absMarker = Math.abs(marker);
const absTop = Math.abs(top);
if (absMarker >= absTop) {
stack.pop();
return;
}
// nested/open inner fence using longer marker for robustness.
stack.push(ticks);
stack.push(marker);
}
function hasMeaningfulText(text) {

View File

@@ -1,14 +1,47 @@
'use strict';
const XML_TOOL_SEGMENT_TAGS = [
'<|dsml|tool_calls>', '<|dsml|tool_calls\n', '<|dsml|tool_calls ',
'<|dsml|invoke ', '<|dsml|invoke\n', '<|dsml|invoke\t', '<|dsml|invoke\r',
'<|dsmltool_calls>', '<|dsmltool_calls\n', '<|dsmltool_calls ',
'<|dsmlinvoke ', '<|dsmlinvoke\n', '<|dsmlinvoke\t', '<|dsmlinvoke\r',
'<|dsml tool_calls>', '<|dsml tool_calls\n', '<|dsml tool_calls ',
'<|dsml invoke ', '<|dsml invoke\n', '<|dsml invoke\t', '<|dsml invoke\r',
'<dsml|tool_calls>', '<dsml|tool_calls\n', '<dsml|tool_calls ',
'<dsml|invoke ', '<dsml|invoke\n', '<dsml|invoke\t', '<dsml|invoke\r',
'<dsmltool_calls>', '<dsmltool_calls\n', '<dsmltool_calls ',
'<dsmlinvoke ', '<dsmlinvoke\n', '<dsmlinvoke\t', '<dsmlinvoke\r',
'<dsml tool_calls>', '<dsml tool_calls\n', '<dsml tool_calls ',
'<dsml invoke ', '<dsml invoke\n', '<dsml invoke\t', '<dsml invoke\r',
'<tool_calls>', '<tool_calls\n', '<tool_calls ',
'<invoke ', '<invoke\n', '<invoke\t', '<invoke\r',
'<|tool_calls>', '<|tool_calls\n', '<|tool_calls ',
'<|invoke ', '<|invoke\n', '<|invoke\t', '<|invoke\r',
'<tool_calls>', '<tool_calls\n', '<tool_calls ',
'<invoke ', '<invoke\n', '<invoke\t', '<invoke\r',
];
const XML_TOOL_OPENING_TAGS = [
'<|dsml|tool_calls',
'<|dsmltool_calls',
'<|dsml tool_calls',
'<dsml|tool_calls',
'<dsmltool_calls',
'<dsml tool_calls',
'<tool_calls',
'<|tool_calls',
'<tool_calls',
];
const XML_TOOL_CLOSING_TAGS = [
'</|dsml|tool_calls>',
'</|dsmltool_calls>',
'</|dsml tool_calls>',
'</dsml|tool_calls>',
'</dsmltool_calls>',
'</dsml tool_calls>',
'</tool_calls>',
'</|tool_calls>',
'</tool_calls>',
];

View File

@@ -3,14 +3,17 @@
const fs = require('fs');
const path = require('path');
const DEFAULT_CLIENT = Object.freeze({
name: 'DeepSeek',
platform: 'android',
androidApiLevel: '35',
locale: 'zh_CN',
});
const DEFAULT_BASE_HEADERS = Object.freeze({
Host: 'chat.deepseek.com',
'User-Agent': 'DeepSeek/1.8.0 Android/35',
Accept: 'application/json',
'Content-Type': 'application/json',
'x-client-platform': 'android',
'x-client-version': '1.8.0',
'x-client-locale': 'zh_CN',
'accept-charset': 'UTF-8',
});
@@ -29,38 +32,96 @@ const DEFAULT_SKIP_EXACT_PATHS = Object.freeze([
'response/search_status',
]);
function loadSharedConstants() {
const sharedPath = path.resolve(__dirname, '../../internal/deepseek/constants_shared.json');
try {
const raw = fs.readFileSync(sharedPath, 'utf8');
const parsed = JSON.parse(raw);
const baseHeaders = parsed && typeof parsed.base_headers === 'object' && !Array.isArray(parsed.base_headers)
? { ...DEFAULT_BASE_HEADERS, ...parsed.base_headers }
: { ...DEFAULT_BASE_HEADERS };
const skipPatterns = Array.isArray(parsed && parsed.skip_contains_patterns)
? parsed.skip_contains_patterns.filter((v) => typeof v === 'string' && v !== '')
: [...DEFAULT_SKIP_PATTERNS];
const skipExactPaths = Array.isArray(parsed && parsed.skip_exact_paths)
? parsed.skip_exact_paths.filter((v) => typeof v === 'string' && v !== '')
: [...DEFAULT_SKIP_EXACT_PATHS];
return {
baseHeaders,
skipPatterns,
skipExactPaths,
};
} catch (_err) {
return {
baseHeaders: { ...DEFAULT_BASE_HEADERS },
skipPatterns: [...DEFAULT_SKIP_PATTERNS],
skipExactPaths: [...DEFAULT_SKIP_EXACT_PATHS],
};
function asNonEmptyString(value) {
return typeof value === 'string' && value !== '' ? value : '';
}
function normalizeClient(raw) {
const client = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
return {
name: asNonEmptyString(client.name) || DEFAULT_CLIENT.name,
platform: asNonEmptyString(client.platform) || DEFAULT_CLIENT.platform,
version: asNonEmptyString(client.version),
androidApiLevel: asNonEmptyString(client.android_api_level) || DEFAULT_CLIENT.androidApiLevel,
locale: asNonEmptyString(client.locale) || DEFAULT_CLIENT.locale,
};
}
function buildBaseHeaders(parsed, client) {
const rawBaseHeaders = parsed && typeof parsed.base_headers === 'object' && !Array.isArray(parsed.base_headers)
? parsed.base_headers
: {};
const baseHeaders = { ...DEFAULT_BASE_HEADERS, ...rawBaseHeaders };
if (client.name && client.version) {
const androidSuffix = client.platform === 'android' && client.androidApiLevel
? ` Android/${client.androidApiLevel}`
: '';
baseHeaders['User-Agent'] = `${client.name}/${client.version}${androidSuffix}`;
}
if (client.platform) {
baseHeaders['x-client-platform'] = client.platform;
}
if (client.version) {
baseHeaders['x-client-version'] = client.version;
}
if (client.locale) {
baseHeaders['x-client-locale'] = client.locale;
}
return baseHeaders;
}
function sharedConstantsPaths() {
return [
path.resolve(__dirname, '../../deepseek/protocol/constants_shared.json'),
path.resolve(process.cwd(), 'internal/deepseek/protocol/constants_shared.json'),
];
}
function readSharedConstants() {
try {
return require('../../deepseek/protocol/constants_shared.json');
} catch (_err) {
// Fall through to filesystem candidates for test and local execution variants.
}
for (const sharedPath of sharedConstantsPaths()) {
try {
const raw = fs.readFileSync(sharedPath, 'utf8');
return JSON.parse(raw);
} catch (_err) {
// Try the next candidate path; fall back to in-file structural defaults below.
}
}
return {};
}
function loadSharedConstants() {
const parsed = readSharedConstants();
const client = normalizeClient(parsed && parsed.client);
const skipPatterns = Array.isArray(parsed && parsed.skip_contains_patterns)
? parsed.skip_contains_patterns.filter((v) => typeof v === 'string' && v !== '')
: [...DEFAULT_SKIP_PATTERNS];
const skipExactPaths = Array.isArray(parsed && parsed.skip_exact_paths)
? parsed.skip_exact_paths.filter((v) => typeof v === 'string' && v !== '')
: [...DEFAULT_SKIP_EXACT_PATHS];
return {
client,
baseHeaders: buildBaseHeaders(parsed, client),
skipPatterns,
skipExactPaths,
};
}
const shared = loadSharedConstants();
module.exports = {
CLIENT: Object.freeze({ ...shared.client }),
CLIENT_VERSION: shared.client.version,
BASE_HEADERS: Object.freeze(shared.baseHeaders),
SKIP_PATTERNS: Object.freeze(shared.skipPatterns),
SKIP_EXACT_PATHS: new Set(shared.skipExactPaths),
__test: {
buildBaseHeaders,
normalizeClient,
sharedConstantsPaths,
},
};

View File

@@ -30,11 +30,6 @@ func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool
Text string
}
processed := make([]block, 0, len(messages))
if thinkingEnabled {
if instruction := buildConversationContinuityInstructions(thinkingEnabled); strings.TrimSpace(instruction) != "" {
processed = append(processed, block{Role: "system", Text: instruction})
}
}
for _, m := range messages {
role, _ := m["role"].(string)
text := NormalizeContent(m["content"])
@@ -93,17 +88,6 @@ func formatRoleBlock(marker, text, endMarker string) string {
return out
}
func buildConversationContinuityInstructions(thinkingEnabled bool) string {
lines := []string{
"Continue the conversation from the full prior context and the latest tool results.",
"Treat earlier messages as binding context; answer the user's current request as a continuation, not a restart.",
}
if thinkingEnabled {
lines = append(lines, "Keep reasoning internal. Do not leave the final user-facing answer only in reasoning; always provide the answer in visible assistant content.")
}
return strings.Join(lines, "\n")
}
func NormalizeContent(v any) string {
if v == nil {
return ""

View File

@@ -58,23 +58,14 @@ func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
}
}
func TestMessagesPrepareWithThinkingAddsContinuityContract(t *testing.T) {
func TestMessagesPrepareWithThinkingPreservesPromptShape(t *testing.T) {
messages := []map[string]any{{"role": "user", "content": "Question"}}
gotThinking := MessagesPrepareWithThinking(messages, true)
gotPlain := MessagesPrepareWithThinking(messages, false)
if gotThinking == gotPlain {
t.Fatalf("expected thinking-enabled prompt to include extra continuity instructions")
if gotThinking != gotPlain {
t.Fatalf("expected thinking flag not to add extra continuity instructions, got thinking=%q plain=%q", gotThinking, gotPlain)
}
if !strings.HasSuffix(gotThinking, "<Assistant>") {
t.Fatalf("expected assistant suffix, got %q", gotThinking)
}
if !strings.Contains(gotThinking, "Continue the conversation from the full prior context") {
t.Fatalf("expected continuity instruction in thinking prompt, got %q", gotThinking)
}
if !strings.Contains(gotThinking, "final user-facing answer only in reasoning") {
t.Fatalf("expected visible-answer instruction in thinking prompt, got %q", gotThinking)
}
if strings.Contains(gotPlain, "Continue the conversation from the full prior context") {
t.Fatalf("did not expect thinking-only instruction in plain prompt, got %q", gotPlain)
}
}

View File

@@ -38,7 +38,7 @@ func FormatToolCallsForPrompt(raw any) string {
if len(blocks) == 0 {
return ""
}
return "<tool_calls>\n" + strings.Join(blocks, "\n") + "\n</tool_calls>"
return "<|DSML|tool_calls>\n" + strings.Join(blocks, "\n") + "\n</|DSML|tool_calls>"
}
// StringifyToolCallArguments normalizes tool arguments into a compact string
@@ -94,12 +94,12 @@ func formatToolCallForPrompt(call map[string]any) string {
parameters := formatToolCallParametersForPrompt(argsRaw)
if parameters == "" {
return ` <invoke name="` + escapeXMLAttribute(name) + `"></invoke>`
return ` <|DSML|invoke name="` + escapeXMLAttribute(name) + `"></|DSML|invoke>`
}
return " <invoke name=\"" + escapeXMLAttribute(name) + "\">\n" +
return " <|DSML|invoke name=\"" + escapeXMLAttribute(name) + "\">\n" +
parameters + "\n" +
" </invoke>"
" </|DSML|invoke>"
}
func formatToolCallParametersForPrompt(raw any) string {
@@ -113,7 +113,7 @@ func formatToolCallParametersForPrompt(raw any) string {
if strings.TrimSpace(fallback) == "" {
return ""
}
return " <parameter name=\"content\">" + renderPromptXMLText(fallback) + "</parameter>"
return " <|DSML|parameter name=\"content\">" + renderPromptXMLText(fallback) + "</|DSML|parameter>"
}
func renderPromptToolParameters(value any, indent string) (string, bool) {
@@ -149,9 +149,9 @@ func renderPromptToolParameters(value any, indent string) (string, bool) {
}
return strings.Join(lines, "\n"), true
case string:
return indent + `<parameter name="content">` + renderPromptXMLText(v) + `</parameter>`, true
return indent + `<|DSML|parameter name="content">` + renderPromptXMLText(v) + `</|DSML|parameter>`, true
default:
return indent + `<parameter name="value">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
return indent + `<|DSML|parameter name="value">` + renderPromptXMLText(fmt.Sprint(v)) + `</|DSML|parameter>`, true
}
}
@@ -162,29 +162,29 @@ func renderPromptParameterNode(name string, value any, indent string) (string, b
}
switch v := value.(type) {
case nil:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
case map[string]any:
body, ok := renderPromptToolXMLBody(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</|DSML|parameter>`, true
case []any:
body, ok := renderPromptToolXMLArray(v, indent+" ")
if !ok {
return "", false
}
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `"></parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `"></|DSML|parameter>`, true
}
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + "\">\n" + body + "\n" + indent + `</|DSML|parameter>`, true
case string:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(v) + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(v) + `</|DSML|parameter>`, true
default:
return indent + `<parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(fmt.Sprint(v)) + `</parameter>`, true
return indent + `<|DSML|parameter name="` + escapeXMLAttribute(trimmedName) + `">` + renderPromptXMLText(fmt.Sprint(v)) + `</|DSML|parameter>`, true
}
}

View File

@@ -9,7 +9,7 @@ func TestStringifyToolCallArgumentsPreservesConcatenatedJSON(t *testing.T) {
}
}
func TestFormatToolCallsForPromptXML(t *testing.T) {
func TestFormatToolCallsForPromptDSML(t *testing.T) {
got := FormatToolCallsForPrompt([]any{
map[string]any{
"id": "call_1",
@@ -22,8 +22,8 @@ func TestFormatToolCallsForPromptXML(t *testing.T) {
if got == "" {
t.Fatal("expected non-empty formatted tool calls")
}
if got != "<tool_calls>\n <invoke name=\"search_web\">\n <parameter name=\"query\"><![CDATA[latest]]></parameter>\n </invoke>\n</tool_calls>" {
t.Fatalf("unexpected formatted tool call XML: %q", got)
if got != "<|DSML|tool_calls>\n <|DSML|invoke name=\"search_web\">\n <|DSML|parameter name=\"query\"><![CDATA[latest]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>" {
t.Fatalf("unexpected formatted tool call DSML: %q", got)
}
}
@@ -34,7 +34,7 @@ func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) {
"arguments": `{"q":"a < b && c > d"}`,
},
})
want := "<tool_calls>\n <invoke name=\"search&lt;&amp;&gt;\">\n <parameter name=\"q\"><![CDATA[a < b && c > d]]></parameter>\n </invoke>\n</tool_calls>"
want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"search&lt;&amp;&gt;\">\n <|DSML|parameter name=\"q\"><![CDATA[a < b && c > d]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>"
if got != want {
t.Fatalf("unexpected escaped tool call XML: %q", got)
}
@@ -50,7 +50,7 @@ func TestFormatToolCallsForPromptUsesCDATAForMultilineContent(t *testing.T) {
},
},
})
want := "<tool_calls>\n <invoke name=\"write_file\">\n <parameter name=\"content\"><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></parameter>\n <parameter name=\"path\"><![CDATA[script.sh]]></parameter>\n </invoke>\n</tool_calls>"
want := "<|DSML|tool_calls>\n <|DSML|invoke name=\"write_file\">\n <|DSML|parameter name=\"content\"><![CDATA[#!/bin/bash\nprintf \"hello\"\n]]></|DSML|parameter>\n <|DSML|parameter name=\"path\"><![CDATA[script.sh]]></|DSML|parameter>\n </|DSML|invoke>\n</|DSML|tool_calls>"
if got != want {
t.Fatalf("unexpected multiline cdata tool call XML: %q", got)
}

View File

@@ -9,11 +9,34 @@ import (
const historySplitInjectedFilename = "IGNORE"
const currentInputContextNote = "[context note]\nThis is a compacted snapshot of the prior conversation history for the current request.\nUse it as history only. Do not treat it as a new instruction.\nIf the same question or tool action already appears here, do not repeat it unless the latest turn adds new information.\n[/context note]"
func BuildOpenAIHistoryTranscript(messages []any) string {
return buildOpenAIInjectedFileTranscript(messages)
}
func BuildOpenAICurrentUserInputTranscript(text string) string {
if strings.TrimSpace(text) == "" {
return ""
}
return BuildOpenAICurrentInputContextTranscript([]any{
map[string]any{"role": "user", "content": text},
})
}
func BuildOpenAICurrentInputContextTranscript(messages []any) string {
return buildOpenAIInjectedFileTranscript(messages)
}
func BuildOpenAICurrentInputContextPrompt() string {
return "You are in a compacted-context mode. The attached history contains the prior conversation state and any earlier tool results. Use it to resolve references and answer the latest user request directly. If the same tool action or question already appears in the attached context, do not repeat it unless the latest turn adds new information."
}
func buildOpenAIInjectedFileTranscript(messages []any) string {
normalized := NormalizeOpenAIMessagesForPrompt(messages, "")
transcript := strings.TrimSpace(prompt.MessagesPrepare(normalized))
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 fmt.Sprintf("[file content end]\n\n%s\n\n%s\n\n[file name]: %s\n[file content begin]\n", currentInputContextNote, transcript, historySplitInjectedFilename)
}

View File

@@ -38,10 +38,10 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized))
}
assistantContent, _ := normalized[2]["content"].(string)
if !strings.Contains(assistantContent, "<tool_calls>") {
t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent)
if !strings.Contains(assistantContent, "<|DSML|tool_calls>") {
t.Fatalf("assistant tool history should be preserved in DSML form, got %q", assistantContent)
}
if !strings.Contains(assistantContent, `<invoke name="get_weather">`) {
if !strings.Contains(assistantContent, `<|DSML|invoke name="get_weather">`) {
t.Fatalf("expected tool name in preserved history, got %q", assistantContent)
}
if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) {
@@ -49,7 +49,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes
}
prompt := util.MessagesPrepare(normalized)
if !strings.Contains(prompt, "<tool_calls>") {
if !strings.Contains(prompt, "<|DSML|tool_calls>") {
t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt)
}
}
@@ -177,10 +177,10 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara
t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized)
}
content, _ := normalized[0]["content"].(string)
if strings.Count(content, "<invoke name=") != 2 {
if strings.Count(content, "<|DSML|invoke name=") != 2 {
t.Fatalf("expected two preserved tool call blocks, got %q", content)
}
if !strings.Contains(content, `<invoke name="search_web">`) || !strings.Contains(content, `<invoke name="eval_javascript">`) {
if !strings.Contains(content, `<|DSML|invoke name="search_web">`) || !strings.Contains(content, `<|DSML|invoke name="eval_javascript">`) {
t.Fatalf("expected both tool names in preserved history, got %q", content)
}
}
@@ -258,7 +258,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi
if strings.Contains(content, "null") {
t.Fatalf("expected no null literal injection, got %q", content)
}
if !strings.Contains(content, "<tool_calls>") {
if !strings.Contains(content, "<|DSML|tool_calls>") {
t.Fatalf("expected assistant tool history in normalized content, got %q", content)
}
}

View File

@@ -47,10 +47,10 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes
if !strings.Contains(finalPrompt, `"condition":"sunny"`) {
t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "<tool_calls>") {
if !strings.Contains(finalPrompt, "<|DSML|tool_calls>") {
t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, `<invoke name="get_weather">`) {
if !strings.Contains(finalPrompt, `<|DSML|invoke name="get_weather">`) {
t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt)
}
}
@@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.") {
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
}
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {
@@ -88,16 +88,14 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
}
}
func TestBuildOpenAIFinalPromptWithThinkingAddsContinuationContract(t *testing.T) {
func TestBuildOpenAIFinalPromptWithThinkingKeepsPromptUnchanged(t *testing.T) {
messages := []any{
map[string]any{"role": "user", "content": "继续回答上一个问题"},
}
finalPrompt, _ := buildOpenAIFinalPrompt(messages, nil, "", true)
if !strings.Contains(finalPrompt, "Continue the conversation from the full prior context") {
t.Fatalf("expected continuation contract in thinking prompt, got=%q", finalPrompt)
}
if !strings.Contains(finalPrompt, "final user-facing answer only in reasoning") {
t.Fatalf("expected visible-answer contract in thinking prompt, got=%q", finalPrompt)
finalPromptThinking, _ := buildOpenAIFinalPrompt(messages, nil, "", true)
finalPromptPlain, _ := buildOpenAIFinalPrompt(messages, nil, "", false)
if finalPromptThinking != finalPromptPlain {
t.Fatalf("expected thinking flag not to prepend continuation contract, thinking=%q plain=%q", finalPromptThinking, finalPromptPlain)
}
}

View File

@@ -25,6 +25,9 @@ func NormalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
}
defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled)
if config.IsNoThinkingModel(resolvedModel) {
thinkingEnabled = false
}
responseModel := strings.TrimSpace(model)
if responseModel == "" {
responseModel = resolvedModel
@@ -65,6 +68,9 @@ func NormalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
}
defaultThinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
thinkingEnabled := util.ResolveThinkingEnabled(req, defaultThinkingEnabled)
if config.IsNoThinkingModel(resolvedModel) {
thinkingEnabled = false
}
// Keep width-control as an explicit policy hook even if current default is true.
allowWideInput := true

View File

@@ -3,21 +3,22 @@ package promptcompat
import "ds2api/internal/config"
type StandardRequest struct {
Surface string
RequestedModel string
ResolvedModel string
ResponseModel string
Messages []any
HistoryText string
ToolsRaw any
FinalPrompt string
ToolNames []string
ToolChoice ToolChoicePolicy
Stream bool
Thinking bool
Search bool
RefFileIDs []string
PassThrough map[string]any
Surface string
RequestedModel string
ResolvedModel string
ResponseModel string
Messages []any
HistoryText string
CurrentInputFileApplied bool
ToolsRaw any
FinalPrompt string
ToolNames []string
ToolChoice ToolChoicePolicy
Stream bool
Thinking bool
Search bool
RefFileIDs []string
PassThrough map[string]any
}
type ToolChoiceMode string

View File

@@ -11,6 +11,7 @@ func TestStandardRequestCompletionPayloadSetsModelTypeFromResolvedModel(t *testi
modelType string
}{
{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"},
}

View File

@@ -0,0 +1,73 @@
package promptcompat
import "strings"
const (
ThinkingInjectionMarker = "Reasoning Effort: Absolute maximum with no shortcuts permitted."
DefaultThinkingInjectionPrompt = ThinkingInjectionMarker + "\n" +
"You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n" +
"Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked."
)
func AppendThinkingInjectionToLatestUser(messages []any) ([]any, bool) {
return AppendThinkingInjectionPromptToLatestUser(messages, "")
}
func AppendThinkingInjectionPromptToLatestUser(messages []any, injectionPrompt string) ([]any, bool) {
if len(messages) == 0 {
return messages, false
}
injectionPrompt = strings.TrimSpace(injectionPrompt)
if injectionPrompt == "" {
injectionPrompt = DefaultThinkingInjectionPrompt
}
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
if strings.ToLower(strings.TrimSpace(asString(msg["role"]))) != "user" {
continue
}
content := msg["content"]
normalizedContent := NormalizeOpenAIContentForPrompt(content)
if strings.Contains(normalizedContent, ThinkingInjectionMarker) || strings.Contains(normalizedContent, injectionPrompt) {
return messages, false
}
updatedContent := appendThinkingInjectionToContent(content, injectionPrompt)
out := append([]any(nil), messages...)
cloned := make(map[string]any, len(msg))
for k, v := range msg {
cloned[k] = v
}
cloned["content"] = updatedContent
out[i] = cloned
return out, true
}
return messages, false
}
func appendThinkingInjectionToContent(content any, injectionPrompt string) any {
switch x := content.(type) {
case string:
return appendTextBlock(x, injectionPrompt)
case []any:
out := append([]any(nil), x...)
out = append(out, map[string]any{
"type": "text",
"text": injectionPrompt,
})
return out
default:
text := NormalizeOpenAIContentForPrompt(content)
return appendTextBlock(text, injectionPrompt)
}
}
func appendTextBlock(base, addition string) string {
base = strings.TrimSpace(base)
if base == "" {
return addition
}
return base + "\n\n" + addition
}

View File

@@ -0,0 +1,81 @@
package promptcompat
import (
"strings"
"testing"
)
func TestAppendThinkingInjectionToLatestUserStringContent(t *testing.T) {
messages := []any{
map[string]any{"role": "user", "content": "older"},
map[string]any{"role": "assistant", "content": "ok"},
map[string]any{"role": "user", "content": "latest"},
}
out, changed := AppendThinkingInjectionToLatestUser(messages)
if !changed {
t.Fatal("expected thinking injection to be appended")
}
latest := out[2].(map[string]any)
content, _ := latest["content"].(string)
if !strings.Contains(content, "latest\n\n"+ThinkingInjectionMarker) {
t.Fatalf("expected injection after latest user text, got %q", content)
}
older := out[0].(map[string]any)
if older["content"] != "older" {
t.Fatalf("expected older user message unchanged, got %#v", older["content"])
}
}
func TestAppendThinkingInjectionToLatestUserArrayContent(t *testing.T) {
messages := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "text", "text": "latest"},
},
},
}
out, changed := AppendThinkingInjectionToLatestUser(messages)
if !changed {
t.Fatal("expected thinking injection to be appended")
}
content, _ := out[0].(map[string]any)["content"].([]any)
if len(content) != 2 {
t.Fatalf("expected appended text block, got %#v", content)
}
block, _ := content[1].(map[string]any)
if block["type"] != "text" || !strings.Contains(block["text"].(string), ThinkingInjectionMarker) {
t.Fatalf("unexpected appended block: %#v", block)
}
}
func TestAppendThinkingInjectionToLatestUserCustomPrompt(t *testing.T) {
messages := []any{
map[string]any{"role": "user", "content": "latest"},
}
out, changed := AppendThinkingInjectionPromptToLatestUser(messages, "custom thinking format")
if !changed {
t.Fatal("expected custom thinking injection to be appended")
}
content, _ := out[0].(map[string]any)["content"].(string)
if !strings.Contains(content, "latest\n\ncustom thinking format") {
t.Fatalf("expected custom injection after latest user text, got %q", content)
}
}
func TestAppendThinkingInjectionToLatestUserSkipsDuplicate(t *testing.T) {
messages := []any{
map[string]any{"role": "user", "content": "latest\n\n" + DefaultThinkingInjectionPrompt},
}
out, changed := AppendThinkingInjectionToLatestUser(messages)
if changed {
t.Fatal("expected duplicate injection to be skipped")
}
if len(out) != 1 {
t.Fatalf("unexpected messages: %#v", out)
}
}

View File

@@ -5,15 +5,18 @@ import (
"strings"
dsprotocol "ds2api/internal/deepseek/protocol"
"ds2api/internal/util"
)
// CollectResult holds the aggregated text and thinking content from a
// DeepSeek SSE stream, consumed to completion (non-streaming use case).
type CollectResult struct {
Text string
Thinking string
ContentFilter bool
CitationLinks map[int]string
Text string
Thinking string
ToolDetectionThinking string
ContentFilter bool
CitationLinks map[int]string
ResponseMessageID int
}
// CollectStream fully consumes a DeepSeek SSE response and separates
@@ -28,9 +31,11 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
}
text := strings.Builder{}
thinking := strings.Builder{}
toolDetectionThinking := strings.Builder{}
contentFilter := false
stopped := false
collector := newCitationLinkCollector()
responseMessageID := 0
currentType := "text"
if thinkingEnabled {
currentType = "thinking"
@@ -39,6 +44,7 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
chunk, done, parsed := ParseDeepSeekSSELine(line)
if parsed && !done {
collector.ingestChunk(chunk)
observeResponseMessageID(chunk, &responseMessageID)
}
if done {
return false
@@ -70,12 +76,44 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
text.WriteString(trimmed)
}
}
for _, p := range result.ToolDetectionThinkingParts {
trimmed := TrimContinuationOverlap(toolDetectionThinking.String(), p.Text)
toolDetectionThinking.WriteString(trimmed)
}
return true
})
return CollectResult{
Text: text.String(),
Thinking: thinking.String(),
ContentFilter: contentFilter,
CitationLinks: collector.build(),
Text: text.String(),
Thinking: thinking.String(),
ToolDetectionThinking: toolDetectionThinking.String(),
ContentFilter: contentFilter,
CitationLinks: collector.build(),
ResponseMessageID: responseMessageID,
}
}
// observeResponseMessageID extracts the response_message_id from a parsed SSE
// chunk. It mirrors the extraction logic in client_continue.go's observe
// method, checking top-level response_message_id, v.response.message_id, and
// message.response.message_id.
func observeResponseMessageID(chunk map[string]any, out *int) {
if chunk == nil || out == nil {
return
}
if id := util.IntFrom(chunk["response_message_id"]); id > 0 {
*out = id
}
v, _ := chunk["v"].(map[string]any)
if response, _ := v["response"].(map[string]any); response != nil {
if id := util.IntFrom(response["message_id"]); id > 0 {
*out = id
}
}
if message, _ := chunk["message"].(map[string]any); message != nil {
if response, _ := message["response"].(map[string]any); response != nil {
if id := util.IntFrom(response["message_id"]); id > 0 {
*out = id
}
}
}
}

View File

@@ -1,15 +1,19 @@
package sse
import "fmt"
import (
"fmt"
)
// LineResult is the normalized parse result for one DeepSeek SSE line.
type LineResult struct {
Parsed bool
Stop bool
ContentFilter bool
ErrorMessage string
Parts []ContentPart
NextType string
Parsed bool
Stop bool
ContentFilter bool
ErrorMessage string
Parts []ContentPart
ToolDetectionThinkingParts []ContentPart
NextType string
ResponseMessageID int
}
// ParseDeepSeekContentLine centralizes one-line DeepSeek SSE parsing for both
@@ -46,12 +50,17 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
NextType: currentType,
}
}
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
parts, detectionThinkingParts, finished, nextType := ParseSSEChunkForContentDetailed(chunk, thinkingEnabled, currentType)
parts = filterLeakedContentFilterParts(parts)
detectionThinkingParts = filterLeakedContentFilterParts(detectionThinkingParts)
var respMsgID int
observeResponseMessageID(chunk, &respMsgID)
return LineResult{
Parsed: true,
Stop: finished,
Parts: parts,
NextType: nextType,
Parsed: true,
Stop: finished,
Parts: parts,
ToolDetectionThinkingParts: detectionThinkingParts,
NextType: nextType,
ResponseMessageID: respMsgID,
}
}

View File

@@ -69,20 +69,25 @@ func isFragmentStatusPath(path string) bool {
}
func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, bool, string) {
parts, _, finished, nextType := ParseSSEChunkForContentDetailed(chunk, thinkingEnabled, currentFragmentType)
return parts, finished, nextType
}
func ParseSSEChunkForContentDetailed(chunk map[string]any, thinkingEnabled bool, currentFragmentType string) ([]ContentPart, []ContentPart, bool, string) {
v, ok := chunk["v"]
if !ok {
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
path, _ := chunk["p"].(string)
if shouldSkipPath(path) {
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
if isStatusPath(path) {
if s, ok := v.(string); ok {
if strings.EqualFold(strings.TrimSpace(s), "FINISHED") {
return nil, true, currentFragmentType
return nil, nil, true, currentFragmentType
}
return nil, false, currentFragmentType
return nil, nil, false, currentFragmentType
}
}
newType := currentFragmentType
@@ -92,18 +97,32 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
partType := resolvePartType(path, thinkingEnabled, newType)
finished := appendChunkValueContent(v, partType, &newType, &parts, path)
if finished {
return nil, true, newType
return nil, nil, true, newType
}
var transitioned bool
parts, transitioned = splitThinkingParts(parts)
if transitioned {
newType = "text"
}
detectionThinkingParts := selectThinkingParts(parts)
if !thinkingEnabled {
parts = dropThinkingParts(parts)
newType = "text"
}
return parts, false, newType
return parts, detectionThinkingParts, false, newType
}
func selectThinkingParts(parts []ContentPart) []ContentPart {
if len(parts) == 0 {
return nil
}
out := make([]ContentPart, 0, len(parts))
for _, p := range parts {
if p.Type == "thinking" {
out = append(out, p)
}
}
return out
}
func collectDirectFragments(path string, chunk map[string]any, v any, newType *string, parts *[]ContentPart) {

View File

@@ -0,0 +1,66 @@
package toolcall
import (
"strings"
"testing"
)
// 4 反引号嵌套 3 反引号
func TestStripFencedCodeBlocks_NestedFourBackticks(t *testing.T) {
text := "Before\n\x60\x60\x60\x60markdown\nHere is \x60\x60\x60 nested \x60\x60\x60 example\n\x60\x60\x60\x60\nAfter"
got := stripFencedCodeBlocks(text)
if !strings.Contains(got, "Before") || !strings.Contains(got, "After") {
t.Fatalf("expected Before and After preserved, got %q", got)
}
if strings.Contains(got, "nested") {
t.Fatalf("expected nested content stripped, got %q", got)
}
}
// 波浪线围栏
func TestStripFencedCodeBlocks_TildeFence(t *testing.T) {
text := "Before\n~~~python\ncode here\n~~~\nAfter"
got := stripFencedCodeBlocks(text)
if !strings.Contains(got, "Before") || !strings.Contains(got, "After") {
t.Fatalf("expected Before/After, got %q", got)
}
if strings.Contains(got, "code here") {
t.Fatalf("expected code stripped, got %q", got)
}
}
// 未闭合围栏 + 后面跟真正的工具调用:不应返回空字符串
func TestStripFencedCodeBlocks_UnclosedFencePreservesToolCall(t *testing.T) {
text := "Example:\n\x60\x60\x60xml\n<tool_calls><invoke name=\"read_file\"><parameter name=\"path\">README.md</parameter></invoke></tool_calls>\n\n<tool_calls><invoke name=\"search\"><parameter name=\"q\">go</parameter></invoke></tool_calls>"
got := stripFencedCodeBlocks(text)
if got == "" {
t.Fatalf("unclosed fence should not truncate everything — real tool call after the fence is lost")
}
}
// CDATA 内的围栏不应被剥离
func TestStripFencedCodeBlocks_FenceInsideCDATA(t *testing.T) {
text := "<tool_calls><invoke name=\"write\">\n<parameter name=\"content\"><![CDATA[\n\x60\x60\x60python\nprint('hello')\n\x60\x60\x60\n]]></parameter>\n</invoke></tool_calls>"
got := stripFencedCodeBlocks(text)
if !strings.Contains(got, "\x60\x60\x60python") {
t.Fatalf("fenced code inside CDATA should be preserved, got %q", got)
}
}
// 连续多个围栏
func TestStripFencedCodeBlocks_MultipleFences(t *testing.T) {
text := "Before\n\x60\x60\x60\nfence1\n\x60\x60\x60\nMiddle\n\x60\x60\x60\nfence2\n\x60\x60\x60\nAfter"
got := stripFencedCodeBlocks(text)
if !strings.Contains(got, "Before") || !strings.Contains(got, "Middle") || !strings.Contains(got, "After") {
t.Fatalf("expected non-fenced content preserved, got %q", got)
}
}
// 围栏包含内嵌 ``` 行但没有独立成行
func TestStripFencedCodeBlocks_InlineBackticksNotFence(t *testing.T) {
text := "Before\n\x60\x60\x60go\nfmt.Println(\x60\x60\x60hello\x60\x60\x60)\n\x60\x60\x60\nAfter"
got := stripFencedCodeBlocks(text)
if !strings.Contains(got, "Before") || !strings.Contains(got, "After") {
t.Fatalf("expected Before/After, got %q", got)
}
}

View File

@@ -12,9 +12,9 @@ func TestRegression_RobustXMLAndCDATA(t *testing.T) {
expected []ParsedToolCall
}{
{
name: "Standard JSON parameters (Regression)",
name: "Standard JSON scalar parameters (Regression)",
text: `<tool_calls><invoke name="foo"><parameter name="a">1</parameter></invoke></tool_calls>`,
expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"a": "1"}}},
expected: []ParsedToolCall{{Name: "foo", Input: map[string]any{"a": float64(1)}}},
},
{
name: "XML tags parameters (Regression)",

View File

@@ -11,44 +11,45 @@ import "strings"
func BuildToolCallInstructions(toolNames []string) string {
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
<tool_calls>
<invoke name="TOOL_NAME_HERE">
<parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></parameter>
</invoke>
</tool_calls>
<|DSML|tool_calls>
<|DSML|invoke name="TOOL_NAME_HERE">
<|DSML|parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>
RULES:
1) Use the <tool_calls> XML wrapper format only.
2) Put one or more <invoke> entries under a single <tool_calls> root.
3) Put the tool name in the invoke name attribute: <invoke name="TOOL_NAME">.
1) Use the <|DSML|tool_calls> wrapper format.
2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root.
3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">.
4) All string values must use <![CDATA[...]]>, even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries.
5) Every top-level argument must be a <parameter name="ARG_NAME">...</parameter> node.
5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">...</|DSML|parameter> node.
6) Objects use nested XML elements inside the parameter body. Arrays may repeat <item> children.
7) Numbers, booleans, and null stay plain text.
8) Use only the parameter names in the tool schema. Do not invent fields.
9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <tool_calls>.
11) Never omit the opening <tool_calls> tag, even if you already plan to close with </tool_calls>.
10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>.
11) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with </|DSML|tool_calls>.
12) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above.
PARAMETER SHAPES:
- string => <parameter name="x"><![CDATA[value]]></parameter>
- object => <parameter name="x"><field>...</field></parameter>
- array => <parameter name="x"><item>...</item><item>...</item></parameter>
- number/bool/null => <parameter name="x">plain_text</parameter>
- string => <|DSML|parameter name="x"><![CDATA[value]]></|DSML|parameter>
- object => <|DSML|parameter name="x"><field>...</field></|DSML|parameter>
- array => <|DSML|parameter name="x"><item>...</item><item>...</item></|DSML|parameter>
- number/bool/null => <|DSML|parameter name="x">plain_text</|DSML|parameter>
【WRONG — Do NOT do these】:
Wrong 1 — mixed text after XML:
<tool_calls>...</tool_calls> I hope this helps.
<|DSML|tool_calls>...</|DSML|tool_calls> I hope this helps.
Wrong 2 — Markdown code fences:
` + "```xml" + `
<tool_calls>...</tool_calls>
<|DSML|tool_calls>...</|DSML|tool_calls>
` + "```" + `
Wrong 3 — missing opening wrapper:
<invoke name="TOOL_NAME">...</invoke>
</tool_calls>
<|DSML|invoke name="TOOL_NAME">...</|DSML|invoke>
</|DSML|tool_calls>
Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.
Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.
` + buildCorrectToolExamples(toolNames)
}
@@ -140,21 +141,21 @@ func firstScriptExample(names []string) (promptToolExample, bool) {
func renderToolExampleBlock(calls []promptToolExample) string {
var b strings.Builder
b.WriteString("<tool_calls>\n")
b.WriteString("<|DSML|tool_calls>\n")
for _, call := range calls {
b.WriteString(` <invoke name="`)
b.WriteString(` <|DSML|invoke name="`)
b.WriteString(call.name)
b.WriteString("\">\n")
b.WriteString(`">` + "\n")
b.WriteString(indentPromptParameters(call.params, " "))
b.WriteString("\n </invoke>\n")
b.WriteString("\n </|DSML|invoke>\n")
}
b.WriteString("</tool_calls>")
b.WriteString("</|DSML|tool_calls>")
return b.String()
}
func indentPromptParameters(body, indent string) string {
if strings.TrimSpace(body) == "" {
return indent + `<parameter name="content"></parameter>`
return indent + `<|DSML|parameter name="content"></|DSML|parameter>`
}
lines := strings.Split(body, "\n")
for i, line := range lines {
@@ -168,7 +169,7 @@ func indentPromptParameters(body, indent string) string {
}
func wrapParameter(name, inner string) string {
return `<parameter name="` + name + `">` + inner + `</parameter>`
return `<|DSML|parameter name="` + name + `">` + inner + `</|DSML|parameter>`
}
func exampleBasicParams(name string) (string, bool) {
@@ -194,7 +195,7 @@ func exampleBasicParams(name string) (string, bool) {
case "Edit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true
case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, true
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true
}
return "", false
}
@@ -202,11 +203,11 @@ func exampleBasicParams(name string) (string, bool) {
func exampleNestedParams(name string) (string, bool) {
switch strings.TrimSpace(name) {
case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, true
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true
case "Task":
return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true
case "ask_followup_question":
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></parameter>`, true
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></|DSML|parameter>`, true
}
return "", false
}

Some files were not shown because too many files have changed in this diff Show More