mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
Compare commits
27 Commits
v3.1.0
...
v3.1.2_bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89eaf048c3 | ||
|
|
904211469a | ||
|
|
530872ff2f | ||
|
|
fbe1e25c7b | ||
|
|
cd7e03d936 | ||
|
|
37fb758191 | ||
|
|
fb6be8a8ee | ||
|
|
57114a36f5 | ||
|
|
a671d82759 | ||
|
|
da75ed6966 | ||
|
|
3b99d2edbe | ||
|
|
f6c09ebd63 | ||
|
|
36af2e00f6 | ||
|
|
9e0fd83a76 | ||
|
|
a8c160b05d | ||
|
|
89ca57122c | ||
|
|
6b6ce3eea8 | ||
|
|
870144de17 | ||
|
|
1530246e4f | ||
|
|
d6ecdad6de | ||
|
|
2857a171cc | ||
|
|
eb8b45e667 | ||
|
|
1664349a29 | ||
|
|
b105d54c00 | ||
|
|
039d7d3db1 | ||
|
|
49012a227c | ||
|
|
4d36afea4c |
10
.github/workflows/quality-gates.yml
vendored
10
.github/workflows/quality-gates.yml
vendored
@@ -28,6 +28,16 @@ jobs:
|
||||
cache: "npm"
|
||||
cache-dependency-path: webui/package-lock.json
|
||||
|
||||
- name: Setup golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v2.11.4
|
||||
install-mode: binary
|
||||
verify: true
|
||||
|
||||
- name: Go Format & Lint Gates
|
||||
run: ./scripts/lint.sh
|
||||
|
||||
- name: Refactor Line Gate
|
||||
run: ./tests/scripts/check-refactor-line-gate.sh
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,3 +59,6 @@ Thumbs.db
|
||||
# Claude Code
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
# Local tool bootstrap cache
|
||||
.tmp/
|
||||
|
||||
73
.golangci.yml
Normal file
73
.golangci.yml
Normal file
@@ -0,0 +1,73 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 100
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- experimental
|
||||
- opinionated
|
||||
- performance
|
||||
- style
|
||||
disabled-checks:
|
||||
- wrapperFunc
|
||||
- rangeValCopy
|
||||
- hugeParam
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
lll:
|
||||
line-length: 140
|
||||
misspell:
|
||||
locale: US
|
||||
nakedret:
|
||||
max-func-lines: 30
|
||||
prealloc:
|
||||
simple: true
|
||||
range-loops: true
|
||||
for-loops: false
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- path: (.+)\.go$
|
||||
text: "ST1000: at least one file in a package should have a package comment"
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- vendor$
|
||||
- webui/node_modules$
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- ds2api
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- vendor$
|
||||
- webui/node_modules$
|
||||
71
API.en.md
71
API.en.md
@@ -138,6 +138,9 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
|
||||
| POST | `/admin/accounts/sessions/delete-all` | Admin | Delete all sessions for one account |
|
||||
| POST | `/admin/import` | Admin | Batch import keys/accounts |
|
||||
| POST | `/admin/test` | Admin | Test API through service |
|
||||
| POST | `/admin/dev/raw-samples/capture` | Admin | Fire one request and persist it as a raw sample |
|
||||
| GET | `/admin/dev/raw-samples/query` | Admin | Search current in-memory capture chains by prompt keyword |
|
||||
| POST | `/admin/dev/raw-samples/save` | Admin | Persist a selected in-memory capture chain as a raw sample |
|
||||
| POST | `/admin/vercel/sync` | Admin | Sync config to Vercel |
|
||||
| GET | `/admin/vercel/status` | Admin | Vercel sync status |
|
||||
| POST | `/admin/vercel/status` | Admin | Vercel sync status / draft compare |
|
||||
@@ -883,6 +886,74 @@ Test API availability through the service itself.
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /admin/dev/raw-samples/capture`
|
||||
|
||||
Internally issues one `/v1/chat/completions` request through the service, then persists the request metadata and raw upstream SSE into `tests/raw_stream_samples/<sample-id>/`.
|
||||
|
||||
Common request fields:
|
||||
|
||||
| Field | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `message` | No | `你好` | Convenience single-turn user message |
|
||||
| `messages` | No | Auto-derived from `message` | OpenAI-style message array |
|
||||
| `model` | No | `deepseek-chat` | Target model |
|
||||
| `stream` | No | `true` | Recommended to keep streaming enabled so raw SSE is recorded |
|
||||
| `api_key` | No | First configured key | Business API key to use |
|
||||
| `sample_id` | No | Auto-generated | Sample directory name |
|
||||
|
||||
On success, the response headers include:
|
||||
|
||||
- `X-Ds2-Sample-Id`
|
||||
- `X-Ds2-Sample-Dir`
|
||||
- `X-Ds2-Sample-Meta`
|
||||
- `X-Ds2-Sample-Upstream`
|
||||
|
||||
If the request itself succeeds but the process did not record a new upstream capture, the endpoint returns:
|
||||
|
||||
```json
|
||||
{"detail":"no upstream capture was recorded"}
|
||||
```
|
||||
|
||||
### `GET /admin/dev/raw-samples/query`
|
||||
|
||||
Searches the current process's in-memory capture entries and groups `completion + continue` rounds by `chat_session_id`.
|
||||
|
||||
**Query parameters**:
|
||||
|
||||
| Param | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `q` | empty | Fuzzy match against request/response text |
|
||||
| `limit` | `20` | Max number of chains returned |
|
||||
|
||||
**Response fields** include:
|
||||
|
||||
- `items[].chain_key`
|
||||
- `items[].capture_ids`
|
||||
- `items[].round_count`
|
||||
- `items[].initial_label`
|
||||
- `items[].request_preview`
|
||||
- `items[].response_preview`
|
||||
|
||||
### `POST /admin/dev/raw-samples/save`
|
||||
|
||||
Persists one selected in-memory capture chain into `tests/raw_stream_samples/<sample-id>/`.
|
||||
|
||||
Any one of these selectors is accepted:
|
||||
|
||||
```json
|
||||
{"chain_key":"session:xxxx","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"capture_id":"cap_xxx","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"query":"Guangzhou weather","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path`.
|
||||
|
||||
### `POST /admin/vercel/sync`
|
||||
|
||||
| Field | Required | Notes |
|
||||
|
||||
71
API.md
71
API.md
@@ -138,6 +138,9 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
|
||||
| POST | `/admin/accounts/sessions/delete-all` | Admin | 删除某账号的全部会话 |
|
||||
| POST | `/admin/import` | Admin | 批量导入 keys/accounts |
|
||||
| POST | `/admin/test` | Admin | 测试当前 API 可用性 |
|
||||
| POST | `/admin/dev/raw-samples/capture` | Admin | 直接发起一次请求并保存为 raw sample |
|
||||
| GET | `/admin/dev/raw-samples/query` | Admin | 按问题关键词查询当前内存抓包链 |
|
||||
| POST | `/admin/dev/raw-samples/save` | Admin | 把命中的内存抓包链保存为 raw sample |
|
||||
| POST | `/admin/vercel/sync` | Admin | 同步配置到 Vercel |
|
||||
| GET | `/admin/vercel/status` | Admin | Vercel 同步状态 |
|
||||
| POST | `/admin/vercel/status` | Admin | Vercel 同步状态 / 草稿对比 |
|
||||
@@ -886,6 +889,74 @@ data: {"type":"message_stop"}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /admin/dev/raw-samples/capture`
|
||||
|
||||
直接通过服务自身发起一次 `/v1/chat/completions` 请求,并把请求元信息和上游原始 SSE 保存到 `tests/raw_stream_samples/<sample-id>/`。
|
||||
|
||||
常用请求字段:
|
||||
|
||||
| 字段 | 必填 | 默认值 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `message` | 否 | `你好` | 便捷单轮用户消息 |
|
||||
| `messages` | 否 | 自动由 `message` 生成 | OpenAI 风格消息数组 |
|
||||
| `model` | 否 | `deepseek-chat` | 目标模型 |
|
||||
| `stream` | 否 | `true` | 建议保留流式,以记录原始 SSE |
|
||||
| `api_key` | 否 | 配置中第一个 key | 调用业务接口使用的 key |
|
||||
| `sample_id` | 否 | 自动生成 | 样本目录名 |
|
||||
|
||||
成功时会在响应头里附带:
|
||||
|
||||
- `X-Ds2-Sample-Id`
|
||||
- `X-Ds2-Sample-Dir`
|
||||
- `X-Ds2-Sample-Meta`
|
||||
- `X-Ds2-Sample-Upstream`
|
||||
|
||||
如果请求本身成功,但当前进程没有记录到新的上游抓包,会返回:
|
||||
|
||||
```json
|
||||
{"detail":"no upstream capture was recorded"}
|
||||
```
|
||||
|
||||
### `GET /admin/dev/raw-samples/query`
|
||||
|
||||
按关键词查询当前进程内存里的抓包记录,并按 `chat_session_id` 归并 `completion + continue` 链。
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `q` | 空 | 按请求体/响应体关键词模糊匹配 |
|
||||
| `limit` | `20` | 返回链条数上限 |
|
||||
|
||||
**响应字段**包含:
|
||||
|
||||
- `items[].chain_key`
|
||||
- `items[].capture_ids`
|
||||
- `items[].round_count`
|
||||
- `items[].initial_label`
|
||||
- `items[].request_preview`
|
||||
- `items[].response_preview`
|
||||
|
||||
### `POST /admin/dev/raw-samples/save`
|
||||
|
||||
把当前内存中的某条抓包链落盘为 `tests/raw_stream_samples/<sample-id>/`。
|
||||
|
||||
支持以下任一种选中方式:
|
||||
|
||||
```json
|
||||
{"chain_key":"session:xxxx","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"capture_id":"cap_xxx","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"query":"广州天气","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
成功响应会返回 `sample_id`、`dir`、`meta_path`、`upstream_path`。
|
||||
|
||||
### `POST /admin/vercel/sync`
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|
||||
23
README.MD
23
README.MD
@@ -81,7 +81,7 @@ flowchart LR
|
||||
- **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。
|
||||
- **统一执行链路**:Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应。
|
||||
- **适配器分层更清晰**:`internal/adapter/{claude,gemini}` 负责入口/出口协议封装,`internal/adapter/openai` 负责核心执行,DeepSeek 侧调用只保留在 OpenAI 内核中。
|
||||
- **Tool Calling 双运行时对齐**:Go 侧(`internal/util`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。
|
||||
- **Tool Calling 双运行时对齐**:Go 侧(`internal/toolcall`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。
|
||||
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
|
||||
- **流式能力升级**:`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。
|
||||
- **可观测与可运维增强**:`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。
|
||||
@@ -348,8 +348,8 @@ cp opencode.json.example opencode.json
|
||||
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
|
||||
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 |
|
||||
| `DS2API_DEV_PACKET_CAPTURE` | 本地开发抓包开关(记录最近会话请求/响应体) | 本地非 Vercel 默认开启 |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `5` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `2097152` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | 本地抓包保留条数(超出自动淘汰) | `20` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | 单条响应体最大记录字节数 | `5242880` |
|
||||
| `DS2API_ACCOUNT_MAX_INFLIGHT` | 每账号最大并发 in-flight 请求数 | `2` |
|
||||
| `DS2API_ACCOUNT_MAX_QUEUE` | 等待队列上限 | `recommended_concurrency` |
|
||||
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局最大 in-flight 请求数 | `recommended_concurrency` |
|
||||
@@ -403,13 +403,13 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `
|
||||
|
||||
## 本地开发抓包工具
|
||||
|
||||
用于定位「responses 思考流/工具调用」等问题。开启后会自动记录最近 N 条 DeepSeek 对话上游请求体与响应体(默认 5 条,超出自动淘汰)。
|
||||
用于定位「responses 思考流/工具调用」等问题。开启后会自动记录最近 N 条 DeepSeek 对话上游请求体与响应体(默认 20 条,超出自动淘汰;单条响应体默认最多记录 5 MB)。
|
||||
|
||||
启用示例:
|
||||
|
||||
```bash
|
||||
DS2API_DEV_PACKET_CAPTURE=true \
|
||||
DS2API_DEV_PACKET_CAPTURE_LIMIT=5 \
|
||||
DS2API_DEV_PACKET_CAPTURE_LIMIT=20 \
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
@@ -417,6 +417,8 @@ go run ./cmd/ds2api
|
||||
|
||||
- `GET /admin/dev/captures`:查看抓包列表(最新在前)
|
||||
- `DELETE /admin/dev/captures`:清空抓包
|
||||
- `GET /admin/dev/raw-samples/query?q=关键词&limit=20`:按问题关键词查询当前内存抓包,并按 `chat_session_id` 归并 `completion + continue` 链
|
||||
- `POST /admin/dev/raw-samples/save`:把命中的某条抓包链保存为 `tests/raw_stream_samples/<sample-id>/` 回放样本
|
||||
|
||||
返回字段包含:
|
||||
|
||||
@@ -424,6 +426,12 @@ go run ./cmd/ds2api
|
||||
- `response_body`:上游返回的原始流式内容拼接文本
|
||||
- `response_truncated`:是否触发单条大小截断
|
||||
|
||||
保存接口支持用 `query`、`chain_key` 或 `capture_id` 选中目标。例如:
|
||||
|
||||
```json
|
||||
{"query":"广州天气","sample_id":"gz-weather-from-memory"}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
@@ -458,7 +466,8 @@ ds2api/
|
||||
│ ├── stream/ # 统一流式消费引擎
|
||||
│ ├── testsuite/ # 端到端测试框架与用例编排
|
||||
│ ├── translatorcliproxy/ # CLIProxy 桥接与流写入组件
|
||||
│ ├── util/ # 通用工具函数
|
||||
│ ├── toolcall/ # Tool Call 解析、修复与格式化(核心业务逻辑)
|
||||
│ ├── util/ # 通用工具函数(Token 估算、JSON 辅助等)
|
||||
│ ├── version/ # 版本解析 / 比较与 tag 规范化
|
||||
│ └── webui/ # WebUI 静态文件托管与自动构建
|
||||
├── webui/ # React WebUI 源码(Vite + Tailwind)
|
||||
@@ -535,7 +544,7 @@ npm ci --prefix webui && npm run build --prefix webui
|
||||
go test ./...
|
||||
|
||||
# 运行 tool calls 相关测试(调试工具调用问题)
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
|
||||
|
||||
# 运行端到端测试
|
||||
./tests/scripts/run-live.sh
|
||||
|
||||
30
README.en.md
30
README.en.md
@@ -81,7 +81,7 @@ flowchart LR
|
||||
- **Unified routing core**: all protocol entries are now centralized through `internal/server/router.go`, with OpenAI / Claude / Gemini / Admin / WebUI routes registered in one tree to avoid multi-entry drift.
|
||||
- **Unified execution chain**: Claude/Gemini entries are translated by `internal/translatorcliproxy`, then executed through `openai.ChatCompletions` for shared tool-calling and stream semantics, then translated back to the client protocol.
|
||||
- **Cleaner adapter boundaries**: `internal/adapter/{claude,gemini}` handles protocol wrappers, while `internal/adapter/openai` remains the execution core; upstream DeepSeek calls are retained only in the OpenAI core.
|
||||
- **Tool-calling parity across runtimes**: Go (`internal/util`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs.
|
||||
- **Tool-calling parity across runtimes**: Go (`internal/toolcall`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs.
|
||||
- **Config/runtime separation**: static config (`config`) and runtime policy (`settings`) are managed independently via Admin APIs, enabling hot updates and password rotation with JWT invalidation.
|
||||
- **Streaming behavior upgrade**: `/v1/responses` and `/v1/chat/completions` now share a more consistent incremental tool-call emission strategy across SDK ecosystems.
|
||||
- **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop.
|
||||
@@ -353,8 +353,8 @@ cp opencode.json.example opencode.json
|
||||
| `DS2API_VERCEL_INTERNAL_SECRET` | Vercel hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
|
||||
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL seconds | `900` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE` | Local dev packet capture switch (record recent request/response bodies) | Enabled by default on non-Vercel local runtime |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | Number of captured sessions to retain (auto-evict overflow) | `5` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | Max recorded bytes per captured response body | `2097152` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_LIMIT` | Number of captured sessions to retain (auto-evict overflow) | `20` |
|
||||
| `DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES` | Max recorded bytes per captured response body | `5242880` |
|
||||
| `VERCEL_TOKEN` | Vercel sync token | — |
|
||||
| `VERCEL_PROJECT_ID` | Vercel project ID | — |
|
||||
| `VERCEL_TEAM_ID` | Vercel team ID | — |
|
||||
@@ -392,21 +392,22 @@ 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)
|
||||
- In non-code-block context, tool JSON may still be recognized even when mixed with normal prose; surrounding prose can remain as text output.
|
||||
2. `responses` streaming strictly uses official item lifecycle events (`response.output_item.*`, `response.content_part.*`, `response.function_call_arguments.*`)
|
||||
3. Tool names not declared in the `tools` schema are strictly rejected and will not be emitted as valid tool calls
|
||||
2. The parser prioritizes XML/Markup, while also accepting JSON / ANTML / invoke / text-kv, and normalizes everything into the internal tool-call structure
|
||||
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. Valid tool call events are only emitted after passing policy validation, preventing invalid tool names from entering the client execution chain
|
||||
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
|
||||
|
||||
> Note: the current parser still prioritizes “parse successfully whenever possible”; hard allow-list rejection for undeclared tool names is not enabled yet.
|
||||
|
||||
## Local Dev Packet Capture
|
||||
|
||||
This is for debugging issues such as Responses reasoning streaming and tool-call handoff. When enabled, DS2API stores the latest N DeepSeek conversation payload pairs (request body + upstream response body), defaulting to 5 entries with auto-eviction.
|
||||
This is for debugging issues such as Responses reasoning streaming and tool-call handoff. When enabled, DS2API stores the latest N DeepSeek conversation payload pairs (request body + upstream response body), defaulting to 20 entries with auto-eviction; each response body is capped at 5 MB by default.
|
||||
|
||||
Enable example:
|
||||
|
||||
```bash
|
||||
DS2API_DEV_PACKET_CAPTURE=true \
|
||||
DS2API_DEV_PACKET_CAPTURE_LIMIT=5 \
|
||||
DS2API_DEV_PACKET_CAPTURE_LIMIT=20 \
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
@@ -414,6 +415,8 @@ Inspect/clear (Admin JWT required):
|
||||
|
||||
- `GET /admin/dev/captures`: list captured items (newest first)
|
||||
- `DELETE /admin/dev/captures`: clear captured items
|
||||
- `GET /admin/dev/raw-samples/query?q=keyword&limit=20`: search current in-memory captures by prompt keyword and group `completion + continue` by `chat_session_id`
|
||||
- `POST /admin/dev/raw-samples/save`: persist a selected capture chain as `tests/raw_stream_samples/<sample-id>/`
|
||||
|
||||
Response fields include:
|
||||
|
||||
@@ -421,6 +424,12 @@ Response fields include:
|
||||
- `response_body`: concatenated raw upstream stream body text
|
||||
- `response_truncated`: whether body-size truncation happened
|
||||
|
||||
The save endpoint can target a chain by `query`, `chain_key`, or `capture_id`. Example:
|
||||
|
||||
```json
|
||||
{"query":"Guangzhou weather","sample_id":"gz-weather-from-memory"}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
@@ -455,7 +464,8 @@ ds2api/
|
||||
│ ├── stream/ # Unified stream consumption engine
|
||||
│ ├── testsuite/ # End-to-end testsuite framework and case orchestration
|
||||
│ ├── translatorcliproxy/ # CLIProxy bridge and stream writer components
|
||||
│ ├── util/ # Common utilities
|
||||
│ ├── toolcall/ # Tool Call parsing, repair, and formatting (core business logic)
|
||||
│ ├── util/ # Common utilities (Token estimation, JSON helpers, etc.)
|
||||
│ ├── version/ # Version parsing/comparison and tag normalization
|
||||
│ └── webui/ # WebUI static file serving and auto-build
|
||||
├── webui/ # React WebUI source (Vite + Tailwind)
|
||||
|
||||
@@ -30,8 +30,8 @@ func main() {
|
||||
opts.Timeout = time.Duration(timeoutSeconds) * time.Second
|
||||
|
||||
if err := testsuite.Run(context.Background(), opts); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
_, _ = fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "testsuite completed successfully")
|
||||
_, _ = fmt.Fprintln(os.Stdout, "testsuite completed successfully")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ npm install
|
||||
# 3. Start dev server (hot reload)
|
||||
npm run dev
|
||||
# Default: http://localhost:5173, auto-proxies API to backend
|
||||
# host: 0.0.0.0 is not configured, so LAN access is not enabled by default
|
||||
```
|
||||
|
||||
WebUI tech stack:
|
||||
@@ -58,7 +59,7 @@ docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
| Language | Standards |
|
||||
| --- | --- |
|
||||
| **Go** | Run `gofmt` and ensure `go test ./...` passes before committing |
|
||||
| **Go** | Run `./scripts/lint.sh` (gofmt + golangci-lint) and ensure `go test ./...` passes before committing |
|
||||
| **JavaScript/React** | Follow existing project style (functional components) |
|
||||
| **Commit messages** | Use semantic prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `style:`, `perf:`, `chore:` |
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ npm install
|
||||
# 3. 启动开发服务器(热更新)
|
||||
npm run dev
|
||||
# 默认监听 http://localhost:5173,自动代理 API 到后端
|
||||
# 当前未配置 host: 0.0.0.0,因此默认不对局域网开放
|
||||
```
|
||||
|
||||
WebUI 技术栈:
|
||||
@@ -58,7 +59,7 @@ docker-compose -f docker-compose.dev.yml up
|
||||
|
||||
| 语言 | 规范 |
|
||||
| --- | --- |
|
||||
| **Go** | 提交前运行 `gofmt`,确保 `go test ./...` 通过 |
|
||||
| **Go** | 提交前运行 `./scripts/lint.sh`(包含 gofmt+golangci-lint)并确保 `go test ./...` 通过 |
|
||||
| **JavaScript/React** | 保持现有代码风格(函数组件) |
|
||||
| **提交信息** | 使用语义化前缀:`feat:`、`fix:`、`docs:`、`refactor:`、`style:`、`perf:`、`chore:` |
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ cp config.example.json config.json
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
Default address: `http://0.0.0.0:5001` (override with `PORT`).
|
||||
Default local access URL: `http://127.0.0.1:5001`; the server actually binds to `0.0.0.0:5001` (override with `PORT`).
|
||||
|
||||
### 1.2 WebUI Build
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ cp config.example.json config.json
|
||||
go run ./cmd/ds2api
|
||||
```
|
||||
|
||||
默认监听 `http://0.0.0.0:5001`,可通过 `PORT` 环境变量覆盖。
|
||||
默认本地访问地址是 `http://127.0.0.1:5001`;服务实际绑定 `0.0.0.0:5001`,可通过 `PORT` 环境变量覆盖。
|
||||
|
||||
### 1.2 WebUI 构建
|
||||
|
||||
|
||||
@@ -180,10 +180,10 @@ go test ./...
|
||||
|
||||
```bash
|
||||
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
|
||||
|
||||
# 运行单个测试用例
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/
|
||||
|
||||
# 运行 format 相关测试
|
||||
go test -v ./internal/format/...
|
||||
@@ -198,13 +198,13 @@ go test -v ./internal/adapter/openai/...
|
||||
|
||||
```bash
|
||||
# 1. 运行 tool calls 相关的所有测试
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
|
||||
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/
|
||||
|
||||
# 2. 查看测试输出中的详细调试信息
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
|
||||
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/ 2>&1
|
||||
|
||||
# 3. 检查具体测试用例的修复效果
|
||||
# 测试用例位于 internal/util/toolcalls_test.go,包含:
|
||||
# 测试用例位于 internal/toolcall/toolcalls_test.go,包含:
|
||||
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
|
||||
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
|
||||
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
|
||||
@@ -260,6 +260,21 @@ POST /admin/dev/raw-samples/capture
|
||||
|
||||
这个接口会把请求元信息和上游原始流写入 `tests/raw_stream_samples/<sample-id>/`,以后可以直接拿来做回放和字段分析。派生输出会在本地回放时再生成,不再落在样本目录里。
|
||||
|
||||
### 从内存抓包查询并保存样本
|
||||
|
||||
如果问题刚刚在本地复现过,也可以先查当前进程内存里的抓包,再选择性落盘:
|
||||
|
||||
```bash
|
||||
GET /admin/dev/raw-samples/query?q=广州&limit=10
|
||||
POST /admin/dev/raw-samples/save
|
||||
{"chain_key":"session:xxxx","sample_id":"tmp-from-memory"}
|
||||
```
|
||||
|
||||
说明:
|
||||
- `query` 会按 `chat_session_id` 把 `completion + continue` 归并成一条链,适合定位接续思考问题。
|
||||
- `save` 支持用 `query`、`chain_key` 或 `capture_id` 选中目标。
|
||||
- 生成的样本目录仍然是 `tests/raw_stream_samples/<sample-id>/`,可以直接喂给回放脚本。
|
||||
|
||||
### 指定输出目录和超时
|
||||
|
||||
```bash
|
||||
|
||||
@@ -64,7 +64,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
|
||||
rec := httptest.NewRecorder()
|
||||
h.OpenAI.ChatCompletions(rec, proxyReq)
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
for k, vv := range res.Header {
|
||||
for _, v := range vv {
|
||||
@@ -94,7 +94,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
|
||||
rec := httptest.NewRecorder()
|
||||
h.OpenAI.ChatCompletions(rec, proxyReq)
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
for k, vv := range res.Header {
|
||||
@@ -124,7 +124,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store C
|
||||
}
|
||||
|
||||
func (h *Handler) handleClaudeStreamRealtime(w http.ResponseWriter, r *http.Request, resp *http.Response, model string, messages []any, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeClaudeError(w, http.StatusInternalServerError, string(body))
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/prompt"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
func normalizeClaudeMessages(messages []any) []any {
|
||||
@@ -98,9 +98,10 @@ func buildClaudeToolPrompt(tools []any) string {
|
||||
}
|
||||
return "You have access to these tools:\n\n" +
|
||||
strings.Join(toolSchemas, "\n\n") + "\n\n" +
|
||||
util.BuildToolCallInstructions(names)
|
||||
toolcall.BuildToolCallInstructions(names)
|
||||
}
|
||||
|
||||
//nolint:unused // retained for compatibility with pending Claude tool-result prompt flow.
|
||||
func formatClaudeToolResultForPrompt(block map[string]any) string {
|
||||
if block == nil {
|
||||
return ""
|
||||
|
||||
@@ -96,6 +96,7 @@ func looksLikeBase64Payload(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
//nolint:unused // helper kept for compatibility with upcoming sanitize pipeline.
|
||||
func marshalCompactJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma
|
||||
model, _ := req["model"].(string)
|
||||
messagesRaw, _ := req["messages"].([]any)
|
||||
if strings.TrimSpace(model) == "" || len(messagesRaw) == 0 {
|
||||
return claudeNormalizedRequest{}, fmt.Errorf("Request must include 'model' and 'messages'.")
|
||||
return claudeNormalizedRequest{}, fmt.Errorf("request must include 'model' and 'messages'")
|
||||
}
|
||||
if _, ok := req["max_tokens"]; !ok {
|
||||
req["max_tokens"] = 8192
|
||||
|
||||
@@ -96,7 +96,11 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
if !s.thinkingEnabled {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(trimmed)
|
||||
s.closeTextBlock()
|
||||
if !s.thinkingBlockOpen {
|
||||
s.thinkingBlockIndex = s.nextBlockIndex
|
||||
@@ -116,13 +120,17 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
"index": s.thinkingBlockIndex,
|
||||
"delta": map[string]any{
|
||||
"type": "thinking_delta",
|
||||
"thinking": cleanedText,
|
||||
"thinking": trimmed,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
s.text.WriteString(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(trimmed)
|
||||
if s.bufferToolContent {
|
||||
if hasUnclosedCodeFence(s.text.String()) {
|
||||
continue
|
||||
@@ -148,7 +156,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
"index": s.textBlockIndex,
|
||||
"delta": map[string]any{
|
||||
"type": "text_delta",
|
||||
"text": cleanedText,
|
||||
"text": trimmed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -46,9 +47,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
|
||||
if s.bufferToolContent {
|
||||
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames)
|
||||
detected := toolcall.ParseStandaloneToolCalls(finalText, s.toolNames)
|
||||
if len(detected) == 0 && finalText == "" && finalThinking != "" {
|
||||
detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames)
|
||||
detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames)
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
stopReason = "tool_use"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // compatibility hook for native Gemini request normalization path.
|
||||
func collectGeminiPassThrough(req map[string]any) map[string]any {
|
||||
cfg, _ := req["generationConfig"].(map[string]any)
|
||||
if len(cfg) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
//nolint:unused // kept for native Gemini adapter route compatibility.
|
||||
func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[string]any, stream bool) (util.StandardRequest, error) {
|
||||
requestedModel := strings.TrimSpace(routeModel)
|
||||
if requestedModel == "" {
|
||||
@@ -17,13 +18,13 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin
|
||||
|
||||
resolvedModel, ok := config.ResolveModel(store, requestedModel)
|
||||
if !ok {
|
||||
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", requestedModel)
|
||||
return util.StandardRequest{}, fmt.Errorf("model %q is not available", requestedModel)
|
||||
}
|
||||
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
|
||||
|
||||
messagesRaw := geminiMessagesFromRequest(req)
|
||||
if len(messagesRaw) == 0 {
|
||||
return util.StandardRequest{}, fmt.Errorf("Request must include non-empty contents.")
|
||||
return util.StandardRequest{}, fmt.Errorf("request must include non-empty contents")
|
||||
}
|
||||
|
||||
toolsRaw := convertGeminiTools(req["tools"])
|
||||
|
||||
@@ -2,6 +2,7 @@ package gemini
|
||||
|
||||
import "strings"
|
||||
|
||||
//nolint:unused // kept for native Gemini adapter route compatibility.
|
||||
func convertGeminiTools(raw any) []any {
|
||||
tools, _ := raw.([]any)
|
||||
if len(tools) == 0 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -57,7 +58,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
|
||||
rec := httptest.NewRecorder()
|
||||
h.OpenAI.ChatCompletions(rec, proxyReq)
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
for k, vv := range res.Header {
|
||||
for _, v := range vv {
|
||||
@@ -87,7 +88,7 @@ func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream
|
||||
rec := httptest.NewRecorder()
|
||||
h.OpenAI.ChatCompletions(rec, proxyReq)
|
||||
res := rec.Result()
|
||||
defer res.Body.Close()
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
for k, vv := range res.Header {
|
||||
@@ -131,8 +132,9 @@ func writeGeminiErrorFromOpenAI(w http.ResponseWriter, status int, raw []byte) {
|
||||
writeGeminiError(w, status, message)
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini non-stream handling path.
|
||||
func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *http.Response, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -151,6 +153,7 @@ func (h *Handler) handleNonStreamGenerateContent(w http.ResponseWriter, resp *ht
|
||||
))
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini non-stream handling path.
|
||||
func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, finalText string, toolNames []string, outputTokens int) map[string]any {
|
||||
parts := buildGeminiPartsFromFinal(finalText, finalThinking, toolNames)
|
||||
usage := buildGeminiUsage(finalPrompt, finalThinking, finalText, outputTokens)
|
||||
@@ -170,6 +173,7 @@ func buildGeminiGenerateContentResponse(model, finalPrompt, finalThinking, final
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini non-stream handling path.
|
||||
func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens int) map[string]any {
|
||||
promptTokens := util.EstimateTokens(finalPrompt)
|
||||
reasoningTokens := util.EstimateTokens(finalThinking)
|
||||
@@ -185,10 +189,11 @@ func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini non-stream handling path.
|
||||
func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []string) []map[string]any {
|
||||
detected := util.ParseToolCalls(finalText, toolNames)
|
||||
detected := toolcall.ParseToolCalls(finalText, toolNames)
|
||||
if len(detected) == 0 && finalThinking != "" {
|
||||
detected = util.ParseToolCalls(finalThinking, toolNames)
|
||||
detected = toolcall.ParseToolCalls(finalThinking, toolNames)
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
parts := make([]map[string]any, 0, len(detected))
|
||||
|
||||
@@ -17,6 +17,7 @@ type Handler struct {
|
||||
OpenAI OpenAIChatRunner
|
||||
}
|
||||
|
||||
//nolint:unused // used by native Gemini stream/non-stream runtime helpers.
|
||||
func (h *Handler) compatStripReferenceMarkers() bool {
|
||||
if h == nil || h.Store == nil {
|
||||
return true
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
streamengine "ds2api/internal/stream"
|
||||
)
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Request, resp *http.Response, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeGeminiError(w, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -49,6 +50,7 @@ func (h *Handler) handleStreamGenerateContent(w http.ResponseWriter, r *http.Req
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
type geminiStreamRuntime struct {
|
||||
w http.ResponseWriter
|
||||
rc *http.ResponseController
|
||||
@@ -68,6 +70,7 @@ type geminiStreamRuntime struct {
|
||||
outputTokens int
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
func newGeminiStreamRuntime(
|
||||
w http.ResponseWriter,
|
||||
rc *http.ResponseController,
|
||||
@@ -93,6 +96,7 @@ func newGeminiStreamRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
func (s *geminiStreamRuntime) sendChunk(payload map[string]any) {
|
||||
b, _ := json.Marshal(payload)
|
||||
_, _ = s.w.Write([]byte("data: "))
|
||||
@@ -103,6 +107,7 @@ func (s *geminiStreamRuntime) sendChunk(payload map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedDecision {
|
||||
if !parsed.Parsed {
|
||||
return streamengine.ParsedDecision{}
|
||||
@@ -126,11 +131,19 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
contentSeen = true
|
||||
if p.Type == "thinking" {
|
||||
if s.thinkingEnabled {
|
||||
s.thinking.WriteString(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(trimmed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(trimmed)
|
||||
if s.bufferContent {
|
||||
continue
|
||||
}
|
||||
@@ -140,7 +153,7 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
"index": 0,
|
||||
"content": map[string]any{
|
||||
"role": "model",
|
||||
"parts": []map[string]any{{"text": cleanedText}},
|
||||
"parts": []map[string]any{{"text": trimmed}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -150,6 +163,7 @@ func (s *geminiStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse
|
||||
return streamengine.ParsedDecision{ContentSeen: contentSeen}
|
||||
}
|
||||
|
||||
//nolint:unused // retained for native Gemini stream handling path.
|
||||
func (s *geminiStreamRuntime) finalize() {
|
||||
finalThinking := s.thinking.String()
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
|
||||
@@ -42,19 +42,23 @@ func (m testGeminiAuth) Determine(_ *http.Request) (*auth.RequestAuth, error) {
|
||||
|
||||
func (testGeminiAuth) Release(_ *auth.RequestAuth) {}
|
||||
|
||||
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
|
||||
type testGeminiDS struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
|
||||
func (m testGeminiDS) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
|
||||
return "session-id", nil
|
||||
}
|
||||
|
||||
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
|
||||
func (m testGeminiDS) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) {
|
||||
return "pow", nil
|
||||
}
|
||||
|
||||
//nolint:unused // reserved test double for native Gemini DS-call path coverage.
|
||||
func (m testGeminiDS) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
@@ -100,6 +104,7 @@ func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.
|
||||
_, _ = w.Write([]byte(out))
|
||||
}
|
||||
|
||||
//nolint:unused // helper retained for native Gemini stream fixture tests.
|
||||
func makeGeminiUpstreamResponse(lines ...string) *http.Response {
|
||||
body := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
|
||||
@@ -2,6 +2,7 @@ package gemini
|
||||
|
||||
import textclean "ds2api/internal/textclean"
|
||||
|
||||
//nolint:unused // retained for native Gemini output post-processing path.
|
||||
func cleanVisibleOutput(text string, stripReferenceMarkers bool) string {
|
||||
if text == "" {
|
||||
return text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
openaifmt "ds2api/internal/format/openai"
|
||||
"ds2api/internal/sse"
|
||||
streamengine "ds2api/internal/stream"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type chatStreamRuntime struct {
|
||||
@@ -102,7 +102,7 @@ func (s *chatStreamRuntime) sendDone() {
|
||||
func (s *chatStreamRuntime) finalize(finishReason string) {
|
||||
finalThinking := s.thinking.String()
|
||||
finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||
detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
|
||||
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
|
||||
if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
|
||||
finishReason = "tool_calls"
|
||||
delta := map[string]any{
|
||||
@@ -221,15 +221,23 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD
|
||||
}
|
||||
if p.Type == "thinking" {
|
||||
if s.thinkingEnabled {
|
||||
s.thinking.WriteString(cleanedText)
|
||||
delta["reasoning_content"] = cleanedText
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(trimmed)
|
||||
delta["reasoning_content"] = trimmed
|
||||
}
|
||||
} else {
|
||||
s.text.WriteString(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.text.WriteString(trimmed)
|
||||
if !s.bufferToolContent {
|
||||
delta["content"] = cleanedText
|
||||
delta["content"] = trimmed
|
||||
} else {
|
||||
events := processToolSieveChunk(&s.toolSieve, cleanedText, s.toolNames)
|
||||
events := processToolSieveChunk(&s.toolSieve, trimmed, s.toolNames)
|
||||
for _, evt := range events {
|
||||
if len(evt.ToolCallDeltas) > 0 {
|
||||
if !s.emitEarlyToolDeltas {
|
||||
|
||||
@@ -116,7 +116,7 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu
|
||||
|
||||
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
return
|
||||
@@ -143,7 +143,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
|
||||
}
|
||||
|
||||
func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, string(body))
|
||||
|
||||
@@ -107,7 +107,7 @@ type autoDeleteCtxDSStub struct {
|
||||
}
|
||||
|
||||
func (m *autoDeleteCtxDSStub) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) {
|
||||
return m.autoDeleteModeDSStub.DeleteSessionForTokenCtx(ctx, token, sessionID)
|
||||
return m.DeleteSessionForTokenCtx(ctx, token, sessionID)
|
||||
}
|
||||
|
||||
func (m *autoDeleteCtxDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -75,7 +76,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
|
||||
|
||||
// buildToolCallInstructions delegates to the shared util implementation.
|
||||
func buildToolCallInstructions(toolNames []string) string {
|
||||
return util.BuildToolCallInstructions(toolNames)
|
||||
return toolcall.BuildToolCallInstructions(toolNames)
|
||||
}
|
||||
|
||||
func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any {
|
||||
@@ -138,7 +139,7 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam
|
||||
return out
|
||||
}
|
||||
|
||||
func formatFinalStreamToolCallsWithStableIDs(calls []util.ParsedToolCall, ids map[int]string) []map[string]any {
|
||||
func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -106,7 +107,7 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -119,7 +120,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
|
||||
if writeUpstreamEmptyOutputError(w, sanitizedThinking, sanitizedText, result.ContentFilter) {
|
||||
return
|
||||
}
|
||||
textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
|
||||
textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames)
|
||||
logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text")
|
||||
|
||||
callCount := len(textParsed.Calls)
|
||||
@@ -142,7 +143,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
|
||||
}
|
||||
|
||||
func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
writeOpenAIError(w, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -200,7 +201,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request,
|
||||
})
|
||||
}
|
||||
|
||||
func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed util.ToolCallParseResult, channel string) {
|
||||
func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed toolcall.ToolCallParseResult, channel string) {
|
||||
rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames)
|
||||
if !parsed.RejectedByPolicy || len(rejected) == 0 {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -107,7 +108,7 @@ func (s *responsesStreamRuntime) finalize() {
|
||||
s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true)
|
||||
}
|
||||
|
||||
textParsed := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
|
||||
textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
|
||||
detected := textParsed.Calls
|
||||
s.logToolPolicyRejections(textParsed)
|
||||
|
||||
@@ -163,8 +164,8 @@ func (s *responsesStreamRuntime) finalize() {
|
||||
s.sendDone()
|
||||
}
|
||||
|
||||
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCallParseResult) {
|
||||
logRejected := func(parsed util.ToolCallParseResult, channel string) {
|
||||
func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed toolcall.ToolCallParseResult) {
|
||||
logRejected := func(parsed toolcall.ToolCallParseResult, channel string) {
|
||||
rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames)
|
||||
if !parsed.RejectedByPolicy || len(rejected) == 0 {
|
||||
return
|
||||
@@ -205,17 +206,25 @@ func (s *responsesStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Pa
|
||||
if !s.thinkingEnabled {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(cleanedText)
|
||||
s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, cleanedText))
|
||||
trimmed := sse.TrimContinuationOverlap(s.thinking.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.thinking.WriteString(trimmed)
|
||||
s.sendEvent("response.reasoning.delta", openaifmt.BuildResponsesReasoningDeltaPayload(s.responseID, trimmed))
|
||||
continue
|
||||
}
|
||||
|
||||
s.text.WriteString(cleanedText)
|
||||
if !s.bufferToolContent {
|
||||
s.emitTextDelta(cleanedText)
|
||||
trimmed := sse.TrimContinuationOverlap(s.text.String(), cleanedText)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, cleanedText, s.toolNames), true)
|
||||
s.text.WriteString(trimmed)
|
||||
if !s.bufferToolContent {
|
||||
s.emitTextDelta(trimmed)
|
||||
continue
|
||||
}
|
||||
s.processToolStreamEvents(processToolSieveChunk(&s.sieve, trimmed, s.toolNames), true)
|
||||
}
|
||||
|
||||
return streamengine.ParsedDecision{ContentSeen: contentSeen}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
openaifmt "ds2api/internal/format/openai"
|
||||
"ds2api/internal/util"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -208,7 +208,7 @@ func (s *responsesStreamRuntime) emitFunctionCallDeltaEvents(deltas []toolCallDe
|
||||
}
|
||||
}
|
||||
|
||||
func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []util.ParsedToolCall) {
|
||||
func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []toolcall.ParsedToolCall) {
|
||||
for idx, tc := range calls {
|
||||
if strings.TrimSpace(tc.Name) == "" {
|
||||
continue
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
openaifmt "ds2api/internal/format/openai"
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
func (s *responsesStreamRuntime) closeIncompleteFunctionItems() {
|
||||
@@ -57,7 +57,7 @@ func (s *responsesStreamRuntime) closeIncompleteFunctionItems() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []util.ParsedToolCall) map[string]any {
|
||||
func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []toolcall.ParsedToolCall) map[string]any {
|
||||
type indexedItem struct {
|
||||
index int
|
||||
item map[string]any
|
||||
|
||||
@@ -12,11 +12,11 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID
|
||||
model, _ := req["model"].(string)
|
||||
messagesRaw, _ := req["messages"].([]any)
|
||||
if strings.TrimSpace(model) == "" || len(messagesRaw) == 0 {
|
||||
return util.StandardRequest{}, fmt.Errorf("Request must include 'model' and 'messages'.")
|
||||
return util.StandardRequest{}, fmt.Errorf("request must include 'model' and 'messages'")
|
||||
}
|
||||
resolvedModel, ok := config.ResolveModel(store, model)
|
||||
if !ok {
|
||||
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", model)
|
||||
return util.StandardRequest{}, fmt.Errorf("model %q is not available", model)
|
||||
}
|
||||
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
|
||||
responseModel := strings.TrimSpace(model)
|
||||
@@ -48,11 +48,11 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
|
||||
model, _ := req["model"].(string)
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return util.StandardRequest{}, fmt.Errorf("Request must include 'model'.")
|
||||
return util.StandardRequest{}, fmt.Errorf("request must include 'model'")
|
||||
}
|
||||
resolvedModel, ok := config.ResolveModel(store, model)
|
||||
if !ok {
|
||||
return util.StandardRequest{}, fmt.Errorf("Model '%s' is not available.", model)
|
||||
return util.StandardRequest{}, fmt.Errorf("model %q is not available", model)
|
||||
}
|
||||
thinkingEnabled, searchEnabled, _ := config.GetModelConfig(resolvedModel)
|
||||
|
||||
@@ -68,7 +68,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra
|
||||
messagesRaw = msgs
|
||||
}
|
||||
if len(messagesRaw) == 0 {
|
||||
return util.StandardRequest{}, fmt.Errorf("Request must include 'input' or 'messages'.")
|
||||
return util.StandardRequest{}, fmt.Errorf("request must include 'input' or 'messages'")
|
||||
}
|
||||
toolPolicy, err := parseToolChoicePolicy(req["tool_choice"], req["tools"])
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
|
||||
case "required":
|
||||
policy.Mode = util.ToolChoiceRequired
|
||||
default:
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("Unsupported tool_choice: %q", v)
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice: %q", v)
|
||||
}
|
||||
case map[string]any:
|
||||
allowedOverride, hasAllowedOverride, err := parseAllowedToolNames(v["allowed_tools"])
|
||||
@@ -198,7 +198,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
|
||||
policy.ForcedName = name
|
||||
policy.Allowed = namesToSet([]string{name})
|
||||
default:
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("Unsupported tool_choice.type: %q", typ)
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("unsupported tool_choice.type: %q", typ)
|
||||
}
|
||||
default:
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice must be a string or object")
|
||||
@@ -206,7 +206,7 @@ func parseToolChoicePolicy(toolChoiceRaw any, toolsRaw any) (util.ToolChoicePoli
|
||||
|
||||
if policy.Mode == util.ToolChoiceRequired || policy.Mode == util.ToolChoiceForced {
|
||||
if len(declaredNames) == 0 {
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools.", policy.Mode)
|
||||
return util.ToolChoicePolicy{}, fmt.Errorf("tool_choice=%s requires non-empty tools", policy.Mode)
|
||||
}
|
||||
}
|
||||
if policy.Mode == util.ToolChoiceForced {
|
||||
|
||||
@@ -3,7 +3,7 @@ package openai
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/util"
|
||||
"ds2api/internal/toolcall"
|
||||
)
|
||||
|
||||
func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames []string) []toolStreamEvent {
|
||||
@@ -226,7 +226,7 @@ func findToolSegmentStart(s string) int {
|
||||
return start
|
||||
}
|
||||
|
||||
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
|
||||
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
|
||||
captured := state.capture.String()
|
||||
if captured == "" {
|
||||
return "", nil, "", false
|
||||
@@ -267,7 +267,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
|
||||
}
|
||||
prefixPart := captured[:start]
|
||||
suffixPart := captured[end:]
|
||||
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
||||
parsed := toolcall.ParseStandaloneToolCallsDetailed(obj, toolNames)
|
||||
if len(parsed.Calls) == 0 {
|
||||
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
|
||||
// Parsed as tool-call payload but rejected by schema/policy:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
type toolStreamSieveState struct {
|
||||
@@ -12,7 +11,7 @@ type toolStreamSieveState struct {
|
||||
capturing bool
|
||||
recentTextTail string
|
||||
pendingToolRaw string
|
||||
pendingToolCalls []util.ParsedToolCall
|
||||
pendingToolCalls []toolcall.ParsedToolCall
|
||||
disableDeltas bool
|
||||
toolNameSent bool
|
||||
toolName string
|
||||
@@ -24,7 +23,7 @@ type toolStreamSieveState struct {
|
||||
|
||||
type toolStreamEvent struct {
|
||||
Content string
|
||||
ToolCalls []util.ParsedToolCall
|
||||
ToolCalls []toolcall.ParsedToolCall
|
||||
ToolCallDeltas []toolCallDelta
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
// --- XML tool call support for the streaming sieve ---
|
||||
|
||||
//nolint:unused // kept as explicit tag inventory for future XML sieve refinements.
|
||||
var xmlToolCallClosingTags = []string{"</tool_calls>", "</tool_call>", "</invoke>", "</function_call>", "</function_calls>", "</tool_use>",
|
||||
// Agent-style XML tags (Roo Code, Cline, etc.)
|
||||
"</attempt_completion>", "</ask_followup_question>", "</new_task>", "</result>"}
|
||||
@@ -34,6 +34,8 @@ var xmlToolCallTagPairs = []struct{ open, close string }{
|
||||
}
|
||||
|
||||
// xmlToolCallBlockPattern matches a complete XML tool call block (wrapper or standalone).
|
||||
//
|
||||
//nolint:unused // reserved for future fast-path XML block detection.
|
||||
var xmlToolCallBlockPattern = regexp.MustCompile(`(?is)(<tool_calls>\s*(?:.*?)\s*</tool_calls>|<tool_call>\s*(?:.*?)\s*</tool_call>|<invoke\b[^>]*>(?:.*?)</invoke>|<function_calls?\b[^>]*>(?:.*?)</function_calls?>|<tool_use>(?:.*?)</tool_use>|<attempt_completion>(?:.*?)</attempt_completion>|<ask_followup_question>(?:.*?)</ask_followup_question>|<new_task>(?:.*?)</new_task>)`)
|
||||
|
||||
// xmlToolTagsToDetect is the set of XML tag prefixes used by findToolSegmentStart.
|
||||
@@ -43,7 +45,7 @@ var xmlToolTagsToDetect = []string{"<tool_calls>", "<tool_calls\n", "<tool_call>
|
||||
"<attempt_completion>", "<ask_followup_question>", "<new_task>"}
|
||||
|
||||
// consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text.
|
||||
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
|
||||
func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) {
|
||||
lower := strings.ToLower(captured)
|
||||
// Find the FIRST matching open/close pair, preferring wrapper tags.
|
||||
// Tag pairs are ordered longest-first (e.g. <tool_calls before <tool_call)
|
||||
@@ -66,7 +68,7 @@ func consumeXMLToolCapture(captured string, toolNames []string) (prefix string,
|
||||
xmlBlock := captured[openIdx:closeEnd]
|
||||
prefixPart := captured[:openIdx]
|
||||
suffixPart := captured[closeEnd:]
|
||||
parsed := util.ParseToolCalls(xmlBlock, toolNames)
|
||||
parsed := toolcall.ParseToolCalls(xmlBlock, toolNames)
|
||||
if len(parsed) > 0 {
|
||||
prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart)
|
||||
return prefixPart, parsed, suffixPart, true
|
||||
|
||||
@@ -36,6 +36,8 @@ func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
pr.Post("/import", h.batchImport)
|
||||
pr.Post("/test", h.testAPI)
|
||||
pr.Post("/dev/raw-samples/capture", h.captureRawSample)
|
||||
pr.Get("/dev/raw-samples/query", h.queryRawSampleCaptures)
|
||||
pr.Post("/dev/raw-samples/save", h.saveRawSampleFromCaptures)
|
||||
pr.Post("/vercel/sync", h.syncVercel)
|
||||
pr.Get("/vercel/status", h.vercelStatus)
|
||||
pr.Post("/vercel/status", h.vercelStatus)
|
||||
|
||||
@@ -165,7 +165,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
|
||||
return result
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
result["message"] = fmt.Sprintf("请求失败: HTTP %d", resp.StatusCode)
|
||||
return result
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var parsed any
|
||||
|
||||
@@ -85,7 +85,7 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.Store.Update(func(c *config.Config) error {
|
||||
for _, k := range c.Keys {
|
||||
if k == key {
|
||||
return fmt.Errorf("Key 已存在")
|
||||
return fmt.Errorf("key 已存在")
|
||||
}
|
||||
}
|
||||
c.Keys = append(c.Keys, key)
|
||||
@@ -109,7 +109,7 @@ func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("Key 不存在")
|
||||
return fmt.Errorf("key 不存在")
|
||||
}
|
||||
c.Keys = append(c.Keys[:idx], c.Keys[idx+1:]...)
|
||||
return nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/config"
|
||||
@@ -15,6 +16,11 @@ import (
|
||||
"ds2api/internal/rawsample"
|
||||
)
|
||||
|
||||
type captureChain struct {
|
||||
Key string
|
||||
Entries []devcapture.Entry
|
||||
}
|
||||
|
||||
func (h *Handler) captureRawSample(w http.ResponseWriter, r *http.Request) {
|
||||
if h.OpenAI == nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "OpenAI handler is not configured"})
|
||||
@@ -231,3 +237,312 @@ func cloneMap(in map[string]any) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) queryRawSampleCaptures(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
limit := intFromQuery(r, "limit", 20)
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
chains := buildCaptureChains(devcapture.Global().Snapshot())
|
||||
items := make([]map[string]any, 0, len(chains))
|
||||
for _, chain := range chains {
|
||||
if query != "" && !captureChainMatchesQuery(chain, query) {
|
||||
continue
|
||||
}
|
||||
items = append(items, buildCaptureChainQueryItem(chain, query))
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"query": query,
|
||||
"limit": limit,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) saveRawSampleFromCaptures(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := devcapture.Global().Snapshot()
|
||||
if len(snapshot) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "no capture logs available"})
|
||||
return
|
||||
}
|
||||
|
||||
chain, err := resolveCaptureChainSelection(snapshot, req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sampleID := strings.TrimSpace(fieldString(req, "sample_id"))
|
||||
source := strings.TrimSpace(fieldString(req, "source"))
|
||||
if source == "" {
|
||||
source = "admin/dev/raw-samples/save"
|
||||
}
|
||||
requestPayload := captureChainRequestPayload(chain)
|
||||
|
||||
saved, err := rawsample.Persist(rawsample.PersistOptions{
|
||||
RootDir: config.RawStreamSampleRoot(),
|
||||
SampleID: sampleID,
|
||||
Source: source,
|
||||
Request: requestPayload,
|
||||
Capture: captureSummaryFromEntries(chain.Entries),
|
||||
UpstreamBody: combineCaptureBodies(chain.Entries),
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"sample_id": saved.SampleID,
|
||||
"sample_dir": saved.Dir,
|
||||
"meta_path": saved.MetaPath,
|
||||
"upstream_path": saved.UpstreamPath,
|
||||
"chain_key": chain.Key,
|
||||
"capture_ids": captureChainIDs(chain),
|
||||
"round_count": len(chain.Entries),
|
||||
})
|
||||
}
|
||||
|
||||
func buildCaptureChains(snapshot []devcapture.Entry) []captureChain {
|
||||
if len(snapshot) == 0 {
|
||||
return nil
|
||||
}
|
||||
ordered := make([]devcapture.Entry, len(snapshot))
|
||||
// devcapture snapshots are newest-first because the store prepends entries.
|
||||
// Reverse once so equal-second timestamps can preserve the actual capture
|
||||
// order (completion before continue) under the stable CreatedAt sort below.
|
||||
for i := range snapshot {
|
||||
ordered[len(snapshot)-1-i] = snapshot[i]
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return ordered[i].CreatedAt < ordered[j].CreatedAt
|
||||
})
|
||||
|
||||
byKey := make(map[string]*captureChain, len(ordered))
|
||||
keys := make([]string, 0, len(ordered))
|
||||
for _, entry := range ordered {
|
||||
key := captureChainKey(entry)
|
||||
if key == "" {
|
||||
key = "capture:" + entry.ID
|
||||
}
|
||||
if _, ok := byKey[key]; !ok {
|
||||
byKey[key] = &captureChain{Key: key}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
byKey[key].Entries = append(byKey[key].Entries, entry)
|
||||
}
|
||||
|
||||
chains := make([]captureChain, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
chains = append(chains, *byKey[key])
|
||||
}
|
||||
sort.SliceStable(chains, func(i, j int) bool {
|
||||
return latestCreatedAt(chains[i]) > latestCreatedAt(chains[j])
|
||||
})
|
||||
return chains
|
||||
}
|
||||
|
||||
func captureChainKey(entry devcapture.Entry) string {
|
||||
req := parseCaptureRequestBody(entry.RequestBody)
|
||||
if sessionID := strings.TrimSpace(fieldString(req, "chat_session_id")); sessionID != "" {
|
||||
return "session:" + sessionID
|
||||
}
|
||||
return "capture:" + entry.ID
|
||||
}
|
||||
|
||||
func parseCaptureRequestBody(raw string) map[string]any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func latestCreatedAt(chain captureChain) int64 {
|
||||
var latest int64
|
||||
for _, entry := range chain.Entries {
|
||||
if entry.CreatedAt > latest {
|
||||
latest = entry.CreatedAt
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func captureChainMatchesQuery(chain captureChain, query string) bool {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
for _, entry := range chain.Entries {
|
||||
hay := strings.ToLower(strings.Join([]string{
|
||||
entry.Label,
|
||||
entry.URL,
|
||||
entry.AccountID,
|
||||
entry.RequestBody,
|
||||
entry.ResponseBody,
|
||||
}, "\n"))
|
||||
if strings.Contains(hay, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildCaptureChainQueryItem(chain captureChain, query string) map[string]any {
|
||||
first := chain.Entries[0]
|
||||
last := chain.Entries[len(chain.Entries)-1]
|
||||
requestPreview := previewCaptureChainRequest(chain)
|
||||
responsePreview := previewCaptureChainResponse(chain)
|
||||
|
||||
return map[string]any{
|
||||
"chain_key": chain.Key,
|
||||
"capture_ids": captureChainIDs(chain),
|
||||
"created_at": latestCreatedAt(chain),
|
||||
"round_count": len(chain.Entries),
|
||||
"account_id": nilIfEmpty(strings.TrimSpace(first.AccountID)),
|
||||
"initial_label": first.Label,
|
||||
"initial_url": first.URL,
|
||||
"latest_label": last.Label,
|
||||
"latest_url": last.URL,
|
||||
"request_preview": requestPreview,
|
||||
"response_preview": responsePreview,
|
||||
"query": query,
|
||||
"response_truncated": captureChainHasTruncatedResponse(chain),
|
||||
}
|
||||
}
|
||||
|
||||
func captureChainIDs(chain captureChain) []string {
|
||||
out := make([]string, 0, len(chain.Entries))
|
||||
for _, entry := range chain.Entries {
|
||||
out = append(out, entry.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func previewCaptureChainRequest(chain captureChain) string {
|
||||
for _, entry := range chain.Entries {
|
||||
req := parseCaptureRequestBody(entry.RequestBody)
|
||||
if prompt := strings.TrimSpace(fieldString(req, "prompt")); prompt != "" {
|
||||
return previewText(prompt, 280)
|
||||
}
|
||||
if messages, ok := req["messages"].([]any); ok {
|
||||
var parts []string
|
||||
for _, item := range messages {
|
||||
m, _ := item.(map[string]any)
|
||||
content := strings.TrimSpace(fieldString(m, "content"))
|
||||
if content != "" {
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return previewText(strings.Join(parts, "\n"), 280)
|
||||
}
|
||||
}
|
||||
}
|
||||
return previewText(strings.TrimSpace(chain.Entries[0].RequestBody), 280)
|
||||
}
|
||||
|
||||
func previewCaptureChainResponse(chain captureChain) string {
|
||||
var b strings.Builder
|
||||
for _, entry := range chain.Entries {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(strings.TrimSpace(entry.ResponseBody))
|
||||
if b.Len() >= 280 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return previewText(b.String(), 280)
|
||||
}
|
||||
|
||||
func previewText(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 || len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
return text[:limit] + "..."
|
||||
}
|
||||
|
||||
func captureChainHasTruncatedResponse(chain captureChain) bool {
|
||||
for _, entry := range chain.Entries {
|
||||
if entry.ResponseTruncated {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveCaptureChainSelection(snapshot []devcapture.Entry, req map[string]any) (captureChain, error) {
|
||||
chains := buildCaptureChains(snapshot)
|
||||
if len(chains) == 0 {
|
||||
return captureChain{}, fmt.Errorf("no capture logs available")
|
||||
}
|
||||
|
||||
if chainKey := strings.TrimSpace(fieldString(req, "chain_key")); chainKey != "" {
|
||||
for _, chain := range chains {
|
||||
if chain.Key == chainKey {
|
||||
return chain, nil
|
||||
}
|
||||
}
|
||||
return captureChain{}, fmt.Errorf("capture chain not found")
|
||||
}
|
||||
|
||||
captureID := strings.TrimSpace(fieldString(req, "capture_id"))
|
||||
if captureID == "" {
|
||||
if ids, ok := toStringSlice(req["capture_ids"]); ok && len(ids) > 0 {
|
||||
captureID = strings.TrimSpace(ids[0])
|
||||
}
|
||||
}
|
||||
if captureID != "" {
|
||||
for _, chain := range chains {
|
||||
for _, entry := range chain.Entries {
|
||||
if entry.ID == captureID {
|
||||
return chain, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return captureChain{}, fmt.Errorf("capture id not found")
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(fieldString(req, "query"))
|
||||
if query != "" {
|
||||
for _, chain := range chains {
|
||||
if captureChainMatchesQuery(chain, query) {
|
||||
return chain, nil
|
||||
}
|
||||
}
|
||||
return captureChain{}, fmt.Errorf("no capture chain matched query")
|
||||
}
|
||||
|
||||
return captureChain{}, fmt.Errorf("capture_id, chain_key, or query is required")
|
||||
}
|
||||
|
||||
func captureChainRequestPayload(chain captureChain) any {
|
||||
for _, entry := range chain.Entries {
|
||||
if req := parseCaptureRequestBody(entry.RequestBody); req != nil {
|
||||
return req
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(chain.Entries[0].RequestBody)
|
||||
}
|
||||
|
||||
@@ -230,3 +230,160 @@ func TestCombineCaptureBodiesPreservesOrderAndSeparators(t *testing.T) {
|
||||
t.Fatalf("unexpected combined body: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRawSampleCapturesGroupsBySessionAndMatchesQuestion(t *testing.T) {
|
||||
devcapture.Global().Clear()
|
||||
defer devcapture.Global().Clear()
|
||||
|
||||
recordCapturedResponse(
|
||||
"deepseek_completion",
|
||||
"https://chat.deepseek.com/api/v0/chat/completion",
|
||||
http.StatusOK,
|
||||
map[string]any{
|
||||
"chat_session_id": "session-query-1",
|
||||
"prompt": "用户问题:广州天气怎么样?",
|
||||
},
|
||||
"data: {\"v\":\"先看天气\"}\n\n",
|
||||
)
|
||||
recordCapturedResponse(
|
||||
"deepseek_continue",
|
||||
"https://chat.deepseek.com/api/v0/chat/continue",
|
||||
http.StatusOK,
|
||||
map[string]any{
|
||||
"chat_session_id": "session-query-1",
|
||||
"message_id": 2,
|
||||
},
|
||||
"data: {\"v\":\"再补充一点\"}\n\n",
|
||||
)
|
||||
|
||||
h := &Handler{}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/dev/raw-samples/query?q=广州天气", nil)
|
||||
h.queryRawSampleCaptures(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
items, _ := out["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d body=%s", len(items), rec.Body.String())
|
||||
}
|
||||
item, _ := items[0].(map[string]any)
|
||||
if item["chain_key"] != "session:session-query-1" {
|
||||
t.Fatalf("unexpected chain key: %#v", item["chain_key"])
|
||||
}
|
||||
if int(item["round_count"].(float64)) != 2 {
|
||||
t.Fatalf("expected 2 rounds, got %#v", item["round_count"])
|
||||
}
|
||||
reqPreview, _ := item["request_preview"].(string)
|
||||
if !strings.Contains(reqPreview, "广州天气") {
|
||||
t.Fatalf("expected request preview to contain query, got %q", reqPreview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCaptureChainsPreservesCaptureOrderWhenTimestampsCollide(t *testing.T) {
|
||||
snapshot := []devcapture.Entry{
|
||||
{
|
||||
ID: "cap_continue",
|
||||
CreatedAt: 1712365200,
|
||||
Label: "deepseek_continue",
|
||||
RequestBody: `{"chat_session_id":"session-collision","message_id":2}`,
|
||||
ResponseBody: "data: {\"v\":\"第二段\"}\n\n",
|
||||
},
|
||||
{
|
||||
ID: "cap_completion",
|
||||
CreatedAt: 1712365200,
|
||||
Label: "deepseek_completion",
|
||||
RequestBody: `{"chat_session_id":"session-collision","prompt":"题目"}`,
|
||||
ResponseBody: "data: {\"v\":\"第一段\"}\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
chains := buildCaptureChains(snapshot)
|
||||
if len(chains) != 1 {
|
||||
t.Fatalf("expected 1 chain, got %d", len(chains))
|
||||
}
|
||||
if len(chains[0].Entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(chains[0].Entries))
|
||||
}
|
||||
if chains[0].Entries[0].Label != "deepseek_completion" {
|
||||
t.Fatalf("expected completion first, got %#v", chains[0].Entries)
|
||||
}
|
||||
if chains[0].Entries[1].Label != "deepseek_continue" {
|
||||
t.Fatalf("expected continue second, got %#v", chains[0].Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRawSampleFromCapturesPersistsSelectedChain(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("DS2API_RAW_STREAM_SAMPLE_ROOT", root)
|
||||
devcapture.Global().Clear()
|
||||
defer devcapture.Global().Clear()
|
||||
|
||||
recordCapturedResponse(
|
||||
"deepseek_completion",
|
||||
"https://chat.deepseek.com/api/v0/chat/completion",
|
||||
http.StatusOK,
|
||||
map[string]any{
|
||||
"chat_session_id": "session-save-1",
|
||||
"prompt": "请回答深圳天气",
|
||||
},
|
||||
"data: {\"v\":\"第一段\"}\n\n",
|
||||
)
|
||||
recordCapturedResponse(
|
||||
"deepseek_continue",
|
||||
"https://chat.deepseek.com/api/v0/chat/continue",
|
||||
http.StatusOK,
|
||||
map[string]any{
|
||||
"chat_session_id": "session-save-1",
|
||||
"message_id": 2,
|
||||
},
|
||||
"data: {\"v\":\"第二段\"}\n\n",
|
||||
)
|
||||
|
||||
h := &Handler{}
|
||||
rec := httptest.NewRecorder()
|
||||
reqBody := `{"query":"深圳天气","sample_id":"saved-from-memory"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/dev/raw-samples/save", strings.NewReader(reqBody))
|
||||
h.saveRawSampleFromCaptures(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if out["sample_id"] != "saved-from-memory" {
|
||||
t.Fatalf("unexpected sample id: %#v", out["sample_id"])
|
||||
}
|
||||
if int(out["round_count"].(float64)) != 2 {
|
||||
t.Fatalf("expected round_count=2, got %#v", out["round_count"])
|
||||
}
|
||||
|
||||
sampleDir := filepath.Join(root, "saved-from-memory")
|
||||
upstreamBytes, err := os.ReadFile(filepath.Join(sampleDir, "upstream.stream.sse"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upstream: %v", err)
|
||||
}
|
||||
upstream := string(upstreamBytes)
|
||||
if !strings.Contains(upstream, "第一段") || !strings.Contains(upstream, "第二段") {
|
||||
t.Fatalf("expected combined upstream, got %q", upstream)
|
||||
}
|
||||
metaBytes, err := os.ReadFile(filepath.Join(sampleDir, "meta.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read meta: %v", err)
|
||||
}
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
||||
t.Fatalf("decode meta: %v", err)
|
||||
}
|
||||
reqMeta, _ := meta["request"].(map[string]any)
|
||||
if fieldString(reqMeta, "chat_session_id") != "session-save-1" {
|
||||
t.Fatalf("expected request to come from selected chain, got %#v", meta["request"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ func vercelRequest(ctx context.Context, client *http.Client, method, endpoint st
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
parsed := map[string]any{}
|
||||
_ = json.Unmarshal(b, &parsed)
|
||||
|
||||
@@ -43,7 +43,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
if r.StatusCode < 200 || r.StatusCode >= 300 {
|
||||
resp["check_error"] = "github api status: " + r.Status
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
@@ -130,9 +130,7 @@ func TestMarkTokenInvalidNotConfigToken(t *testing.T) {
|
||||
a := &RequestAuth{UseConfigToken: false, DeepSeekToken: "direct", resolver: r}
|
||||
r.MarkTokenInvalid(a)
|
||||
// Should not panic, token should be unchanged for non-config
|
||||
if a.DeepSeekToken != "" {
|
||||
// Actually it does clear it; that's fine - let's check behavior
|
||||
}
|
||||
_ = a.DeepSeekToken // Actual behavior may clear it; this test only asserts no panic.
|
||||
}
|
||||
|
||||
func TestMarkTokenInvalidEmptyAccountID(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -86,22 +87,22 @@ func TestGoCompatToolcallFixtures(t *testing.T) {
|
||||
mustLoadJSON(t, fixturePath, &fixture)
|
||||
|
||||
var expected struct {
|
||||
Calls []util.ParsedToolCall `json:"calls"`
|
||||
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
|
||||
RejectedByPolicy bool `json:"rejectedByPolicy"`
|
||||
RejectedToolNames []string `json:"rejectedToolNames"`
|
||||
Calls []toolcall.ParsedToolCall `json:"calls"`
|
||||
SawToolCallSyntax bool `json:"sawToolCallSyntax"`
|
||||
RejectedByPolicy bool `json:"rejectedByPolicy"`
|
||||
RejectedToolNames []string `json:"rejectedToolNames"`
|
||||
}
|
||||
mustLoadJSON(t, expectedPath, &expected)
|
||||
|
||||
var got util.ToolCallParseResult
|
||||
var got toolcall.ToolCallParseResult
|
||||
switch strings.ToLower(strings.TrimSpace(fixture.Mode)) {
|
||||
case "standalone":
|
||||
got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
got = toolcall.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
default:
|
||||
got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
got = toolcall.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames)
|
||||
}
|
||||
if got.Calls == nil {
|
||||
got.Calls = []util.ParsedToolCall{}
|
||||
got.Calls = []toolcall.ParsedToolCall{}
|
||||
}
|
||||
if got.RejectedToolNames == nil {
|
||||
got.RejectedToolNames = []string{}
|
||||
|
||||
@@ -58,8 +58,7 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
|
||||
defer func() { _ = tmp.Close() }()
|
||||
if _, err := tmp.WriteString(`{
|
||||
"accounts":[{"email":"u@example.com","password":"p","token":"persisted-token"}]
|
||||
}`); err != nil {
|
||||
@@ -355,7 +354,7 @@ func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
defer tmp.Close()
|
||||
defer func() { _ = tmp.Close() }()
|
||||
if _, err := tmp.WriteString(`{
|
||||
"accounts":[{"email":"u@example.com","password":"p","test_status":"ok"}]
|
||||
}`); err != nil {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) {
|
||||
var seenURL string
|
||||
|
||||
client := &Client{
|
||||
stream: failingDoer{err: errors.New("stream transport failed")},
|
||||
stream: failingDoer{err: errors.New("stream transport failed")},
|
||||
fallbackS: &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seenPow = req.Header.Get("x-ds-pow-response")
|
||||
@@ -54,8 +54,7 @@ func TestCallContinuePropagatesPowHeaderToFallbackRequest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("callContinue returned error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if seenPow != "pow-response-abc" {
|
||||
t.Fatalf("continue request pow header=%q want=%q", seenPow, "pow-response-abc")
|
||||
}
|
||||
@@ -105,8 +104,7 @@ func TestCallCompletionAutoContinueThreadsPowHeader(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CallCompletion returned error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read auto-continued body failed: %v", err)
|
||||
|
||||
@@ -19,7 +19,7 @@ func readResponseBody(resp *http.Response) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
defer func() { _ = gz.Close() }()
|
||||
reader = gz
|
||||
case "br":
|
||||
reader = brotli.NewReader(resp.Body)
|
||||
|
||||
@@ -49,7 +49,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
payloadBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
@@ -86,7 +86,7 @@ func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url str
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
payloadBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
DeepSeekHost = "chat.deepseek.com"
|
||||
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
||||
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
||||
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
||||
DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue"
|
||||
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
|
||||
DeepSeekHost = "chat.deepseek.com"
|
||||
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
|
||||
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
|
||||
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
|
||||
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
|
||||
DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue"
|
||||
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
|
||||
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
|
||||
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLimit = 5
|
||||
defaultMaxBodyBytes = 2 * 1024 * 1024
|
||||
defaultLimit = 20
|
||||
defaultMaxBodyBytes = 5 * 1024 * 1024
|
||||
maxLimit = 50
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,35 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewFromEnvDefaults(t *testing.T) {
|
||||
t.Setenv("DS2API_DEV_PACKET_CAPTURE_LIMIT", "")
|
||||
t.Setenv("DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES", "")
|
||||
t.Setenv("VERCEL", "")
|
||||
t.Setenv("NOW_REGION", "")
|
||||
|
||||
s := NewFromEnv()
|
||||
if s.Limit() != 20 {
|
||||
t.Fatalf("expected default limit 20, got %d", s.Limit())
|
||||
}
|
||||
if s.MaxBodyBytes() != 5*1024*1024 {
|
||||
t.Fatalf("expected default max body bytes 5MB, got %d", s.MaxBodyBytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromEnvHonorsOverrides(t *testing.T) {
|
||||
t.Setenv("DS2API_DEV_PACKET_CAPTURE_LIMIT", "7")
|
||||
t.Setenv("DS2API_DEV_PACKET_CAPTURE_MAX_BODY_BYTES", "8192")
|
||||
t.Setenv("VERCEL", "")
|
||||
t.Setenv("NOW_REGION", "")
|
||||
s := NewFromEnv()
|
||||
if s.Limit() != 7 {
|
||||
t.Fatalf("expected override limit 7, got %d", s.Limit())
|
||||
}
|
||||
if s.MaxBodyBytes() != 8192 {
|
||||
t.Fatalf("expected override max body bytes 8192, got %d", s.MaxBodyBytes())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePushKeepsNewestWithinLimit(t *testing.T) {
|
||||
s := &Store{enabled: true, limit: 2, maxBodyBytes: 1024}
|
||||
for i := 0; i < 3; i++ {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -8,9 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := util.ParseToolCalls(finalText, toolNames)
|
||||
detected := toolcall.ParseToolCalls(finalText, toolNames)
|
||||
if len(detected) == 0 && finalText == "" && finalThinking != "" {
|
||||
detected = util.ParseToolCalls(finalThinking, toolNames)
|
||||
detected = toolcall.ParseToolCalls(finalThinking, toolNames)
|
||||
}
|
||||
content := make([]map[string]any, 0, 4)
|
||||
if finalThinking != "" {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
finishReason := "stop"
|
||||
messageObj := map[string]any{"role": "assistant", "content": finalText}
|
||||
if strings.TrimSpace(finalThinking) != "" {
|
||||
@@ -16,7 +15,7 @@ func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalT
|
||||
}
|
||||
if len(detected.Calls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
|
||||
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected.Calls)
|
||||
messageObj["content"] = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"ds2api/internal/util"
|
||||
)
|
||||
|
||||
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 := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames)
|
||||
exposedOutputText := finalText
|
||||
output := make([]any, 0, 2)
|
||||
if len(detected.Calls) > 0 {
|
||||
@@ -71,7 +70,7 @@ func BuildResponseObjectFromItems(responseID, model, finalPrompt, finalThinking,
|
||||
}
|
||||
}
|
||||
|
||||
func toResponsesFunctionCallItems(toolCalls []util.ParsedToolCall) []any {
|
||||
func toResponsesFunctionCallItems(toolCalls []toolcall.ParsedToolCall) []any {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ func BuildResponsesTextDeltaPayload(responseID, itemID string, outputIndex, cont
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func BuildResponsesTextDonePayload(responseID, itemID string, outputIndex, contentIndex int, text string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "response.output_text.done",
|
||||
|
||||
23
internal/js/chat-stream/dedupe.js
Normal file
23
internal/js/chat-stream/dedupe.js
Normal file
@@ -0,0 +1,23 @@
|
||||
'use strict';
|
||||
|
||||
const MIN_CONTINUATION_SNAPSHOT_LEN = 32;
|
||||
|
||||
function trimContinuationOverlap(existing, incoming) {
|
||||
if (!incoming) {
|
||||
return '';
|
||||
}
|
||||
if (!existing) {
|
||||
return incoming;
|
||||
}
|
||||
if (incoming.length >= MIN_CONTINUATION_SNAPSHOT_LEN && incoming.startsWith(existing)) {
|
||||
return incoming.slice(existing.length);
|
||||
}
|
||||
if (incoming.length >= MIN_CONTINUATION_SNAPSHOT_LEN && existing.startsWith(incoming)) {
|
||||
return '';
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
trimContinuationOverlap,
|
||||
};
|
||||
@@ -34,6 +34,9 @@ const {
|
||||
const {
|
||||
handleVercelStream,
|
||||
} = require('./vercel_stream');
|
||||
const {
|
||||
trimContinuationOverlap,
|
||||
} = require('./dedupe');
|
||||
|
||||
async function handler(req, res) {
|
||||
setCorsHeaders(res);
|
||||
@@ -119,4 +122,5 @@ module.exports.__test = {
|
||||
extractAccumulatedTokenUsage,
|
||||
isNodeStreamSupportedPath,
|
||||
extractPathname,
|
||||
trimContinuationOverlap,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
|
||||
};
|
||||
}
|
||||
|
||||
const outputTokens = extractAccumulatedTokenUsage(chunk);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(chunk, 'error')) {
|
||||
return {
|
||||
parsed: true,
|
||||
@@ -33,7 +35,6 @@ function parseChunkForContent(chunk, thinkingEnabled, currentType, stripReferenc
|
||||
}
|
||||
|
||||
const pathValue = asString(chunk.p);
|
||||
const outputTokens = extractAccumulatedTokenUsage(chunk);
|
||||
|
||||
if (hasContentFilterStatus(chunk)) {
|
||||
return {
|
||||
@@ -465,10 +466,19 @@ function findAccumulatedTokenUsage(v) {
|
||||
}
|
||||
|
||||
function toInt(v) {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
return Math.trunc(v);
|
||||
}
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) {
|
||||
return Math.trunc(n);
|
||||
}
|
||||
}
|
||||
if (typeof v !== 'number') {
|
||||
return 0;
|
||||
}
|
||||
return Math.trunc(v);
|
||||
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
||||
}
|
||||
|
||||
function formatErrorMessage(v) {
|
||||
|
||||
@@ -27,6 +27,9 @@ const {
|
||||
relayPreparedFailure,
|
||||
createLeaseReleaser,
|
||||
} = require('./http_internal');
|
||||
const {
|
||||
trimContinuationOverlap,
|
||||
} = require('./dedupe');
|
||||
|
||||
const DEEPSEEK_COMPLETION_URL = 'https://chat.deepseek.com/api/v0/chat/completion';
|
||||
|
||||
@@ -245,21 +248,29 @@ async function handleVercelStream(req, res, rawBody, payload) {
|
||||
if (!p.text) {
|
||||
continue;
|
||||
}
|
||||
if (searchEnabled && isCitation(p.text)) {
|
||||
continue;
|
||||
}
|
||||
if (p.type === 'thinking') {
|
||||
if (thinkingEnabled) {
|
||||
thinkingText += p.text;
|
||||
sendDeltaFrame({ reasoning_content: p.text });
|
||||
const trimmed = trimContinuationOverlap(thinkingText, p.text);
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
thinkingText += trimmed;
|
||||
sendDeltaFrame({ reasoning_content: trimmed });
|
||||
}
|
||||
} else {
|
||||
outputText += p.text;
|
||||
if (!toolSieveEnabled) {
|
||||
sendDeltaFrame({ content: p.text });
|
||||
const trimmed = trimContinuationOverlap(outputText, p.text);
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const events = processToolSieveChunk(toolSieveState, p.text, toolNames);
|
||||
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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // retained for raw-sample processing entrypoints.
|
||||
func extractProcessedVisibleText(raw []byte, kind, contentType string) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
@@ -22,6 +23,7 @@ func extractProcessedVisibleText(raw []byte, kind, contentType string) string {
|
||||
return parseOpenAIStreamText(string(raw))
|
||||
}
|
||||
|
||||
//nolint:unused // retained for raw-sample processing entrypoints.
|
||||
func parseOpenAIStreamText(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
@@ -54,6 +56,7 @@ func parseOpenAIStreamText(raw string) string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
//nolint:unused // retained for raw-sample processing entrypoints.
|
||||
func parseOpenAIJSONText(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ""
|
||||
@@ -65,6 +68,7 @@ func parseOpenAIJSONText(raw string) string {
|
||||
return extractOpenAIVisibleTextValue(decoded)
|
||||
}
|
||||
|
||||
//nolint:unused // retained for raw-sample processing entrypoints.
|
||||
func extractOpenAIVisibleTextValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
|
||||
@@ -24,7 +24,7 @@ type CollectResult struct {
|
||||
// The caller is responsible for closing resp.Body unless closeBody is true.
|
||||
func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) CollectResult {
|
||||
if closeBody {
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
text := strings.Builder{}
|
||||
thinking := strings.Builder{}
|
||||
@@ -54,9 +54,11 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
|
||||
}
|
||||
for _, p := range result.Parts {
|
||||
if p.Type == "thinking" {
|
||||
thinking.WriteString(p.Text)
|
||||
trimmed := TrimContinuationOverlap(thinking.String(), p.Text)
|
||||
thinking.WriteString(trimmed)
|
||||
} else {
|
||||
text.WriteString(p.Text)
|
||||
trimmed := TrimContinuationOverlap(text.String(), p.Text)
|
||||
text.WriteString(trimmed)
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
30
internal/sse/consumer_test.go
Normal file
30
internal/sse/consumer_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectStreamDedupesContinueSnapshotReplay(t *testing.T) {
|
||||
prefix := "我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。"
|
||||
body := strings.Join([]string{
|
||||
`data: {"v":{"response":{"fragments":[{"id":2,"type":"THINK","content":"` + prefix + `","references":[],"stage_id":1}]}}}`,
|
||||
``,
|
||||
`data: {"p":"response/status","v":"INCOMPLETE"}`,
|
||||
``,
|
||||
`data: {"v":{"response":{"fragments":[{"id":2,"type":"THINK","content":"` + prefix + `继续","references":[],"stage_id":1}]}}}`,
|
||||
``,
|
||||
`data: {"v":"分析"}`,
|
||||
``,
|
||||
`data: {"p":"response/status","v":"FINISHED"}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
resp := &http.Response{Body: io.NopCloser(strings.NewReader(body))}
|
||||
got := CollectStream(resp, true, true)
|
||||
if got.Thinking != prefix+"继续分析" {
|
||||
t.Fatalf("unexpected thinking after dedupe: %q", got.Thinking)
|
||||
}
|
||||
}
|
||||
24
internal/sse/dedupe.go
Normal file
24
internal/sse/dedupe.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package sse
|
||||
|
||||
import "strings"
|
||||
|
||||
const minContinuationSnapshotLen = 32
|
||||
|
||||
// TrimContinuationOverlap removes the already-seen prefix when DeepSeek
|
||||
// continue rounds resend the full fragment snapshot instead of only the new
|
||||
// suffix. Non-overlapping chunks are returned unchanged.
|
||||
func TrimContinuationOverlap(existing, incoming string) string {
|
||||
if incoming == "" {
|
||||
return ""
|
||||
}
|
||||
if existing == "" {
|
||||
return incoming
|
||||
}
|
||||
if len(incoming) >= minContinuationSnapshotLen && strings.HasPrefix(incoming, existing) {
|
||||
return incoming[len(existing):]
|
||||
}
|
||||
if len(incoming) >= minContinuationSnapshotLen && strings.HasPrefix(existing, incoming) {
|
||||
return ""
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
39
internal/sse/dedupe_test.go
Normal file
39
internal/sse/dedupe_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package sse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTrimContinuationOverlapReturnsSuffixForSnapshotReplay(t *testing.T) {
|
||||
existing := "我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。"
|
||||
incoming := existing + "继续分析"
|
||||
got := TrimContinuationOverlap(existing, incoming)
|
||||
if got != "继续分析" {
|
||||
t.Fatalf("expected suffix only, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimContinuationOverlapDropsStaleShorterSnapshot(t *testing.T) {
|
||||
incoming := "我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。"
|
||||
existing := incoming + "继续分析"
|
||||
got := TrimContinuationOverlap(existing, incoming)
|
||||
if got != "" {
|
||||
t.Fatalf("expected stale snapshot to be dropped, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimContinuationOverlapPreservesNormalIncrement(t *testing.T) {
|
||||
existing := "我们"
|
||||
incoming := "被"
|
||||
got := TrimContinuationOverlap(existing, incoming)
|
||||
if got != "被" {
|
||||
t.Fatalf("expected normal increment unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimContinuationOverlapKeepsShortPrefixLikeNormalToken(t *testing.T) {
|
||||
existing := "我们被问到"
|
||||
incoming := "我们"
|
||||
got := TrimContinuationOverlap(existing, incoming)
|
||||
if got != "我们" {
|
||||
t.Fatalf("expected short token preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,9 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
||||
if !parsed {
|
||||
return LineResult{NextType: currentType}
|
||||
}
|
||||
outputTokens := extractAccumulatedTokenUsage(chunk)
|
||||
if done {
|
||||
return LineResult{Parsed: true, Stop: true, NextType: currentType}
|
||||
return LineResult{Parsed: true, Stop: true, NextType: currentType, OutputTokens: outputTokens}
|
||||
}
|
||||
if errObj, hasErr := chunk["error"]; hasErr {
|
||||
return LineResult{
|
||||
@@ -29,6 +30,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
||||
Stop: true,
|
||||
ErrorMessage: fmt.Sprintf("%v", errObj),
|
||||
NextType: currentType,
|
||||
OutputTokens: outputTokens,
|
||||
}
|
||||
}
|
||||
if code, _ := chunk["code"].(string); code == "content_filter" {
|
||||
@@ -37,7 +39,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
||||
Stop: true,
|
||||
ContentFilter: true,
|
||||
NextType: currentType,
|
||||
OutputTokens: extractAccumulatedTokenUsage(chunk),
|
||||
OutputTokens: outputTokens,
|
||||
}
|
||||
}
|
||||
if hasContentFilterStatus(chunk) {
|
||||
@@ -46,16 +48,16 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
||||
Stop: true,
|
||||
ContentFilter: true,
|
||||
NextType: currentType,
|
||||
OutputTokens: extractAccumulatedTokenUsage(chunk),
|
||||
OutputTokens: outputTokens,
|
||||
}
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||
parts = filterLeakedContentFilterParts(parts)
|
||||
return LineResult{
|
||||
Parsed: true,
|
||||
Stop: finished,
|
||||
Parts: parts,
|
||||
NextType: nextType,
|
||||
OutputTokens: extractAccumulatedTokenUsage(chunk),
|
||||
Parsed: true,
|
||||
Stop: finished,
|
||||
Parts: parts,
|
||||
NextType: nextType,
|
||||
OutputTokens: outputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,23 @@ func TestParseDeepSeekContentLineCapturesAccumulatedTokenUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineCapturesAccumulatedTokenUsageString(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response","o":"BATCH","v":[{"p":"accumulated_token_usage","v":"190"},{"p":"quasi_status","v":"FINISHED"}]}`), false, "text")
|
||||
if res.OutputTokens != 190 {
|
||||
t.Fatalf("expected output token usage 190, got %d", res.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineErrorIncludesOutputTokens(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"error":"boom","accumulated_token_usage":123}`), false, "text")
|
||||
if !res.Parsed || !res.Stop {
|
||||
t.Fatalf("expected stop on error: %#v", res)
|
||||
}
|
||||
if res.OutputTokens != 123 {
|
||||
t.Fatalf("expected output token usage 123 on error, got %d", res.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineContent(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"hi"}`), false, "text")
|
||||
if !res.Parsed || res.Stop {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/deepseek"
|
||||
@@ -56,9 +57,7 @@ func isFragmentStatusPath(path string) bool {
|
||||
if mid == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(mid, "-") {
|
||||
mid = mid[1:]
|
||||
}
|
||||
mid = strings.TrimPrefix(mid, "-")
|
||||
if mid == "" {
|
||||
return false
|
||||
}
|
||||
@@ -309,11 +308,12 @@ func extractContentRecursive(items []any, defaultType string) ([]ContentPart, bo
|
||||
}
|
||||
typeName, _ := x["type"].(string)
|
||||
typeName = strings.ToUpper(typeName)
|
||||
if typeName == "THINK" || typeName == "THINKING" {
|
||||
switch typeName {
|
||||
case "THINK", "THINKING":
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "thinking"})
|
||||
} else if typeName == "RESPONSE" {
|
||||
case "RESPONSE":
|
||||
parts = append(parts, ContentPart{Text: ct, Type: "text"})
|
||||
} else {
|
||||
default:
|
||||
parts = append(parts, ContentPart{Text: ct, Type: partType})
|
||||
}
|
||||
case string:
|
||||
@@ -413,6 +413,19 @@ func toInt(v any) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
return int(i), true
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if i, err := strconv.Atoi(s); err == nil {
|
||||
return i, true
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return 0, false
|
||||
}
|
||||
return int(f), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
|
||||
134
internal/sse/raw_stream_token_replay_test.go
Normal file
134
internal/sse/raw_stream_token_replay_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRawStreamSamplesTokenReplay(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "tests", "raw_stream_samples")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("read samples root: %v", err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ssePath := filepath.Join(root, entry.Name(), "upstream.stream.sse")
|
||||
if _, err := os.Stat(ssePath); err != nil {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
t.Run(entry.Name(), func(t *testing.T) {
|
||||
raw, err := os.ReadFile(ssePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read sample: %v", err)
|
||||
}
|
||||
parsedTokens, expectedTokens, err := replayAndCollectTokens(string(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("replay token collection failed: %v", err)
|
||||
}
|
||||
if expectedTokens <= 0 {
|
||||
t.Fatalf("expected positive token usage from raw stream, got %d", expectedTokens)
|
||||
}
|
||||
if parsedTokens != expectedTokens {
|
||||
t.Fatalf("token mismatch parsed=%d expected=%d", parsedTokens, expectedTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if found == 0 {
|
||||
t.Fatalf("no upstream.stream.sse samples found under %s", root)
|
||||
}
|
||||
}
|
||||
|
||||
func replayAndCollectTokens(raw string) (parsedTokens int, expectedTokens int, err error) {
|
||||
currentType := "thinking"
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" || payload == "[DONE]" || !strings.HasPrefix(payload, "{") {
|
||||
continue
|
||||
}
|
||||
var chunk map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
continue
|
||||
}
|
||||
if n := rawAccumulatedTokenUsage(chunk); n > 0 {
|
||||
expectedTokens = n
|
||||
}
|
||||
res := ParseDeepSeekContentLine([]byte(line), true, currentType)
|
||||
currentType = res.NextType
|
||||
if res.OutputTokens > 0 {
|
||||
parsedTokens = res.OutputTokens
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
if errors.Is(scanErr, bufio.ErrTooLong) {
|
||||
return 0, 0, errors.New("raw stream line exceeds 2MiB scanner limit")
|
||||
}
|
||||
return 0, 0, scanErr
|
||||
}
|
||||
return parsedTokens, expectedTokens, nil
|
||||
}
|
||||
|
||||
func rawAccumulatedTokenUsage(v any) int {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
for _, item := range x {
|
||||
if n := rawAccumulatedTokenUsage(item); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if n := rawToInt(x["accumulated_token_usage"]); n > 0 {
|
||||
return n
|
||||
}
|
||||
if p, _ := x["p"].(string); strings.Contains(strings.ToLower(strings.TrimSpace(p)), "accumulated_token_usage") {
|
||||
if n := rawToInt(x["v"]); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
for _, vv := range x {
|
||||
if n := rawAccumulatedTokenUsage(vv); n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func rawToInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestStartParsedLinePumpContextCancellation(t *testing.T) {
|
||||
// Cancel context - this will cause the pump to exit on next send
|
||||
cancel()
|
||||
// Close the pipe to unblock scanner.Scan()
|
||||
pw.Close()
|
||||
_ = pw.Close()
|
||||
|
||||
// Drain remaining results
|
||||
for range results {
|
||||
|
||||
@@ -170,7 +170,7 @@ func (r *Runner) caseToolcallStreamMixed(ctx context.Context, cc *caseContext) e
|
||||
cc.assert("tool_calls_delta_present", hasTool, "tool_calls delta missing")
|
||||
cc.assert("no_raw_tool_json_leak", !rawLeak, "raw tool_calls leaked")
|
||||
cc.assert("done_terminated", done, "expected [DONE]")
|
||||
if !(hasTool && hasText) {
|
||||
if !hasTool || !hasText {
|
||||
r.warnings = append(r.warnings, "toolcall mixed stream did not produce both text and tool_calls in this run (model-side behavior dependent)")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -58,7 +58,7 @@ func (cc *caseContext) abortStreamRequest(ctx context.Context, spec requestSpec)
|
||||
})
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
buf := make([]byte, 512)
|
||||
_, _ = resp.Body.Read(buf)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -66,7 +66,7 @@ func (r *Runner) pruneOldRuns() error {
|
||||
if err := os.RemoveAll(dirPath); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("remove %s: %v", name, err))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stdout, "pruned old test run: %s\n", name)
|
||||
_, _ = fmt.Fprintf(os.Stdout, "pruned old test run: %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (r *Runner) runPreflight(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
for _, step := range steps {
|
||||
if _, err := fmt.Fprintf(f, "\n$ %s\n", strings.Join(step, " ")); err != nil {
|
||||
return err
|
||||
@@ -218,7 +218,7 @@ func (r *Runner) ping(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (cc *caseContext) requestOnce(ctx context.Context, spec requestSpec, attemp
|
||||
cc.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
cc.mu.Lock()
|
||||
@@ -131,7 +131,7 @@ func (cc *caseContext) requestOnce(ctx context.Context, spec requestSpec, attemp
|
||||
})
|
||||
|
||||
if spec.Stream {
|
||||
cc.streamRaw.WriteString(fmt.Sprintf("### trace=%s url=%s\n", traceID, fullURL))
|
||||
_, _ = fmt.Fprintf(&cc.streamRaw, "### trace=%s url=%s\n", traceID, fullURL)
|
||||
cc.streamRaw.Write(body)
|
||||
cc.streamRaw.WriteString("\n\n")
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func findFreePort() (int, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer ln.Close()
|
||||
defer func() { _ = ln.Close() }()
|
||||
addr, ok := ln.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return 0, errors.New("failed to detect tcp port")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import "strings"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
92
internal/toolcall/toolcall_edge_test.go
Normal file
92
internal/toolcall/toolcall_edge_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── FormatOpenAIStreamToolCalls ─────────────────────────────────────
|
||||
|
||||
func TestFormatOpenAIStreamToolCalls(t *testing.T) {
|
||||
formatted := FormatOpenAIStreamToolCalls([]ParsedToolCall{
|
||||
{Name: "search", Input: map[string]any{"q": "test"}},
|
||||
})
|
||||
if len(formatted) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(formatted))
|
||||
}
|
||||
fn, _ := formatted[0]["function"].(map[string]any)
|
||||
if fn["name"] != "search" {
|
||||
t.Fatalf("unexpected function name: %#v", fn)
|
||||
}
|
||||
if formatted[0]["index"] != 0 {
|
||||
t.Fatalf("expected index 0, got %v", formatted[0]["index"])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseToolCalls more edge cases ──────────────────────────────────
|
||||
|
||||
func TestParseToolCallsNoToolNames(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
calls := ParseToolCalls(text, nil)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsEmptyText(t *testing.T) {
|
||||
calls := ParseToolCalls("", []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected 0 calls for empty text, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsMultipleTools(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}},{"name":"get_weather","input":{"city":"beijing"}}]}`
|
||||
calls := ParseToolCalls(text, []string{"search", "get_weather"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsInputAsString(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":"{\"q\":\"golang\"}"}]}`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Input["q"] != "golang" {
|
||||
t.Fatalf("expected parsed string input, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
|
||||
text := `{"tool_calls":[{"function":{"name":"calc","arguments":{"x":1,"y":2}}}]}`
|
||||
calls := ParseToolCalls(text, []string{"calc"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "calc" {
|
||||
t.Fatalf("expected calc, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
|
||||
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
|
||||
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── looksLikeToolExampleContext ─────────────────────────────────────
|
||||
|
||||
func TestLooksLikeToolExampleContextNone(t *testing.T) {
|
||||
if looksLikeToolExampleContext("I will call the tool now") {
|
||||
t.Fatal("expected false for non-example context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeToolExampleContextFenced(t *testing.T) {
|
||||
if !looksLikeToolExampleContext("```json") {
|
||||
t.Fatal("expected true for fenced code block context")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`)
|
||||
var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```")
|
||||
|
||||
//nolint:unused // retained for future markup tool-call heuristics.
|
||||
var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`)
|
||||
|
||||
func buildToolCallCandidates(text string) []string {
|
||||
@@ -190,6 +192,7 @@ func shouldSkipToolCallParsingForCodeFenceExample(text string) bool {
|
||||
return !looksLikeToolCallSyntax(stripped)
|
||||
}
|
||||
|
||||
//nolint:unused // retained for future markup tool-call heuristics.
|
||||
func looksLikeMarkupToolSyntax(text string) bool {
|
||||
return markupToolSyntaxPattern.MatchString(text)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@@ -27,7 +27,7 @@ func repairInvalidJSONBackslashes(s string) string {
|
||||
isHex := true
|
||||
for j := 1; j <= 4; j++ {
|
||||
r := runes[i+1+j]
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,12 +1,14 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused // retained for policy-level tool-name matching compatibility.
|
||||
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
//nolint:unused // retained for policy-level tool-name matching compatibility.
|
||||
func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||
if _, ok := allowed[name]; ok {
|
||||
return name
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -164,6 +164,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
|
||||
return out, nil
|
||||
}
|
||||
|
||||
//nolint:unused // retained for policy-level tool-name matching compatibility.
|
||||
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
|
||||
return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import "strings"
|
||||
|
||||
@@ -7,7 +7,7 @@ func isLikelyJSONToolPayloadCandidate(candidate string) bool {
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
|
||||
if !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
@@ -73,7 +73,6 @@ func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
|
||||
for _, key := range []string{"arguments", "args", "parameters", "params"} {
|
||||
if v, ok := m[key]; ok {
|
||||
inputRaw = v
|
||||
hasInput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
@@ -1,4 +1,4 @@
|
||||
package util
|
||||
package toolcall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -1,6 +1,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"ds2api/internal/toolcall"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
// BuildOpenAIChatCompletion is kept for backward compatibility.
|
||||
// Prefer internal/format/openai.BuildChatCompletion for new code.
|
||||
func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := ParseToolCalls(finalText, toolNames)
|
||||
detected := toolcall.ParseToolCalls(finalText, toolNames)
|
||||
finishReason := "stop"
|
||||
messageObj := map[string]any{"role": "assistant", "content": finalText}
|
||||
if strings.TrimSpace(finalThinking) != "" {
|
||||
@@ -19,7 +20,7 @@ func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking,
|
||||
}
|
||||
if len(detected) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
messageObj["tool_calls"] = FormatOpenAIToolCalls(detected)
|
||||
messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected)
|
||||
messageObj["content"] = nil
|
||||
}
|
||||
promptTokens := EstimateTokens(finalPrompt)
|
||||
@@ -46,7 +47,7 @@ func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking,
|
||||
// BuildOpenAIResponseObject is kept for backward compatibility.
|
||||
// Prefer internal/format/openai.BuildResponseObject for new code.
|
||||
func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := ParseToolCalls(finalText, toolNames)
|
||||
detected := toolcall.ParseToolCalls(finalText, toolNames)
|
||||
exposedOutputText := finalText
|
||||
output := make([]any, 0, 2)
|
||||
if len(detected) > 0 {
|
||||
@@ -108,7 +109,7 @@ func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, fi
|
||||
// BuildClaudeMessageResponse is kept for backward compatibility.
|
||||
// Prefer internal/format/claude.BuildMessageResponse for new code.
|
||||
func BuildClaudeMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
|
||||
detected := ParseToolCalls(finalText, toolNames)
|
||||
detected := toolcall.ParseToolCalls(finalText, toolNames)
|
||||
content := make([]map[string]any, 0, 4)
|
||||
if finalThinking != "" {
|
||||
content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking})
|
||||
|
||||
@@ -355,90 +355,3 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) {
|
||||
t.Fatalf("expected opus to use slow mapping, got %q", out["model"])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FormatOpenAIStreamToolCalls ─────────────────────────────────────
|
||||
|
||||
func TestFormatOpenAIStreamToolCalls(t *testing.T) {
|
||||
formatted := FormatOpenAIStreamToolCalls([]ParsedToolCall{
|
||||
{Name: "search", Input: map[string]any{"q": "test"}},
|
||||
})
|
||||
if len(formatted) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(formatted))
|
||||
}
|
||||
fn, _ := formatted[0]["function"].(map[string]any)
|
||||
if fn["name"] != "search" {
|
||||
t.Fatalf("unexpected function name: %#v", fn)
|
||||
}
|
||||
if formatted[0]["index"] != 0 {
|
||||
t.Fatalf("expected index 0, got %v", formatted[0]["index"])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseToolCalls more edge cases ──────────────────────────────────
|
||||
|
||||
func TestParseToolCallsNoToolNames(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
|
||||
calls := ParseToolCalls(text, nil)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call with nil tool names, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsEmptyText(t *testing.T) {
|
||||
calls := ParseToolCalls("", []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected 0 calls for empty text, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsMultipleTools(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":{"q":"go"}},{"name":"get_weather","input":{"city":"beijing"}}]}`
|
||||
calls := ParseToolCalls(text, []string{"search", "get_weather"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 calls, got %d", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsInputAsString(t *testing.T) {
|
||||
text := `{"tool_calls":[{"name":"search","input":"{\"q\":\"golang\"}"}]}`
|
||||
calls := ParseToolCalls(text, []string{"search"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Input["q"] != "golang" {
|
||||
t.Fatalf("expected parsed string input, got %#v", calls[0].Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
|
||||
text := `{"tool_calls":[{"function":{"name":"calc","arguments":{"x":1,"y":2}}}]}`
|
||||
calls := ParseToolCalls(text, []string{"calc"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0].Name != "calc" {
|
||||
t.Fatalf("expected calc, got %q", calls[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
|
||||
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
|
||||
calls := ParseStandaloneToolCalls(fenced, []string{"search"})
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── looksLikeToolExampleContext ─────────────────────────────────────
|
||||
|
||||
func TestLooksLikeToolExampleContextNone(t *testing.T) {
|
||||
if looksLikeToolExampleContext("I will call the tool now") {
|
||||
t.Fatal("expected false for non-example context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeToolExampleContextFenced(t *testing.T) {
|
||||
if !looksLikeToolExampleContext("```json") {
|
||||
t.Fatal("expected true for fenced code block context")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ internal/adapter/openai/tool_sieve_core.go
|
||||
internal/adapter/openai/tool_sieve_xml.go
|
||||
internal/adapter/openai/tool_sieve_jsonscan.go
|
||||
|
||||
internal/util/toolcalls_parse.go
|
||||
internal/util/toolcalls_candidates.go
|
||||
internal/util/toolcalls_format.go
|
||||
internal/toolcall/toolcalls_parse.go
|
||||
internal/toolcall/toolcalls_candidates.go
|
||||
internal/toolcall/toolcalls_format.go
|
||||
|
||||
internal/adapter/claude/handler_routes.go
|
||||
internal/adapter/claude/handler_messages.go
|
||||
|
||||
105
scripts/lint.sh
Executable file
105
scripts/lint.sh
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
LINT_BIN="${GOLANGCI_LINT_BIN:-golangci-lint}"
|
||||
BOOTSTRAP_VERSION="${GOLANGCI_LINT_VERSION:-v2.11.4}"
|
||||
BOOTSTRAP_BIN="${ROOT_DIR}/.tmp/golangci-lint-${BOOTSTRAP_VERSION}"
|
||||
|
||||
bootstrap_golangci_lint() {
|
||||
local version_no_v os arch artifact archive_url tmp_dir
|
||||
version_no_v="${BOOTSTRAP_VERSION#v}"
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
case "$os" in
|
||||
linux|darwin|windows) ;;
|
||||
*)
|
||||
echo "unsupported OS for bootstrap: ${os}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$arch" in
|
||||
x86_64|amd64) arch="amd64" ;;
|
||||
aarch64|arm64) arch="arm64" ;;
|
||||
*)
|
||||
echo "unsupported architecture for bootstrap: ${arch}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
artifact="${os}-${arch}"
|
||||
archive_url="https://github.com/golangci/golangci-lint/releases/download/${BOOTSTRAP_VERSION}/golangci-lint-${version_no_v}-${artifact}.tar.gz"
|
||||
|
||||
mkdir -p "${ROOT_DIR}/.tmp"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "${tmp_dir}"' RETURN
|
||||
|
||||
curl -sSfL "${archive_url}" -o "${tmp_dir}/golangci-lint.tar.gz"
|
||||
tar -xzf "${tmp_dir}/golangci-lint.tar.gz" -C "${tmp_dir}"
|
||||
cp "${tmp_dir}/golangci-lint-${version_no_v}-${artifact}/golangci-lint" "${BOOTSTRAP_BIN}"
|
||||
chmod +x "${BOOTSTRAP_BIN}"
|
||||
|
||||
echo "bootstrapped golangci-lint ${BOOTSTRAP_VERSION} to ${BOOTSTRAP_BIN}" >&2
|
||||
}
|
||||
|
||||
run_lint() {
|
||||
local bin="$1"
|
||||
if [[ "$bin" == *" "* ]]; then
|
||||
eval "$bin fmt --diff -c .golangci.yml" && eval "$bin run -c .golangci.yml"
|
||||
else
|
||||
"$bin" fmt --diff -c .golangci.yml && "$bin" run -c .golangci.yml
|
||||
fi
|
||||
}
|
||||
|
||||
is_compatibility_error() {
|
||||
case "$1" in
|
||||
*"command not found"*|\
|
||||
*"not recognized as an internal or external command"*|\
|
||||
*"No such file or directory"*|\
|
||||
*"unknown command \"fmt\""*|\
|
||||
*"unknown command \"run\""*|\
|
||||
*"unknown flag"*|\
|
||||
*"no such flag"*|\
|
||||
*"unsupported version of the configuration"*|\
|
||||
*"can't load config"*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# v2 separates formatters from linters; enforce both in one entrypoint.
|
||||
if lint_output="$(run_lint "$LINT_BIN" 2>&1)"; then
|
||||
[[ -n "$lint_output" ]] && echo "$lint_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "${GOLANGCI_LINT_BIN:-}" ]]; then
|
||||
echo "$lint_output" >&2
|
||||
echo "lint failed with explicit GOLANGCI_LINT_BIN=${GOLANGCI_LINT_BIN}; skip auto-bootstrap." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! is_compatibility_error "$lint_output"; then
|
||||
echo "$lint_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "default golangci-lint appears incompatible; bootstrapping ${BOOTSTRAP_VERSION}..." >&2
|
||||
if [[ ! -x "${BOOTSTRAP_BIN}" ]]; then
|
||||
bootstrap_golangci_lint
|
||||
fi
|
||||
|
||||
if lint_output="$(run_lint "${BOOTSTRAP_BIN}" 2>&1)"; then
|
||||
[[ -n "$lint_output" ]] && echo "$lint_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$lint_output" >&2
|
||||
exit 1
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
shouldSkipPath,
|
||||
isNodeStreamSupportedPath,
|
||||
extractPathname,
|
||||
trimContinuationOverlap,
|
||||
} = handler.__test;
|
||||
|
||||
test('chat-stream exposes parser test hooks', () => {
|
||||
@@ -368,3 +369,10 @@ test('extractPathname strips query only', () => {
|
||||
assert.equal(extractPathname('/v1/chat/completions?stream=true'), '/v1/chat/completions');
|
||||
assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent');
|
||||
});
|
||||
|
||||
test('trimContinuationOverlap preserves short normal tokens and trims long snapshots', () => {
|
||||
assert.equal(trimContinuationOverlap('我们被问到', '我们'), '我们');
|
||||
const existing = '我们被问到:这是一个很长的续答快照前缀,用来验证去重逻辑不会误伤正常 token。';
|
||||
const incoming = `${existing}继续分析`;
|
||||
assert.equal(trimContinuationOverlap(existing, incoming), '继续分析');
|
||||
});
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
该目录只保留**上游真实 SSE 原始流**,用于本地回放、字段分析和回归测试。
|
||||
|
||||
## 默认永久样本
|
||||
## 样本分类
|
||||
|
||||
仓库当前保留两份 canonical 默认样本:
|
||||
该目录下的样本分成两类:
|
||||
|
||||
- `guangzhou-weather-reasoner-search-20260404`:包含 `reference:N` 引用标记的天气搜索流,用于验证引用清理与正文输出。
|
||||
- `content-filter-trigger-20260405-jwt3`:真实命中的 `CONTENT_FILTER` 风控流,用于验证终态处理与拒答格式。
|
||||
- canonical 默认样本:由 [`manifest.json`](./manifest.json) 的 `default_samples` 指定,默认回放工具优先跑这组稳定样本
|
||||
- 扩展样本:保留真实问题或特定协议行为,用于排障、字段分析和定向回归,不一定默认纳入全量回放
|
||||
|
||||
默认回放工具会优先读取 [`manifest.json`](./manifest.json) 中的 `default_samples`,以稳定固定回放集。
|
||||
当前目录里除了 canonical 样本,还包含例如:
|
||||
|
||||
- `markdown-format-example-20260405`
|
||||
- `markdown-format-example-20260405-spacefix`
|
||||
- `continue-thinking-snapshot-replay-20260405`
|
||||
|
||||
其中 `continue-thinking-snapshot-replay-20260405` 是一个多轮样本,覆盖 `completion + continue` 的原始 SSE 重放场景,用于验证接续思考去重。
|
||||
|
||||
如果要看默认固定回放集,以 [`manifest.json`](./manifest.json) 为准,而不是按目录数量人工判断。
|
||||
更完整的协议级行为结构说明见 [docs/DeepSeekSSE行为结构说明-2026-04-05.md](../../docs/DeepSeekSSE行为结构说明-2026-04-05.md)。
|
||||
|
||||
## 自动采集接口
|
||||
@@ -29,6 +37,15 @@ POST /admin/dev/raw-samples/capture
|
||||
|
||||
采集接口的响应体仍然是项目当次的实际输出,但它不会再写入样本目录。这样样本树始终只保留原始流,后续回放时再按需本地生成派生结果。
|
||||
|
||||
如果问题已经在当前进程的内存抓包里复现过,也可以先查再存:
|
||||
|
||||
```bash
|
||||
GET /admin/dev/raw-samples/query?q=关键词&limit=20
|
||||
POST /admin/dev/raw-samples/save
|
||||
```
|
||||
|
||||
这条链路适合把“刚刚发生的一次真实问题”快速转成可回放样本,而不用重新触发请求。
|
||||
|
||||
## 目录规范
|
||||
|
||||
每个样本一个子目录,且只保留下面两类文件:
|
||||
@@ -36,6 +53,16 @@ POST /admin/dev/raw-samples/capture
|
||||
- `meta.json`:样本元信息(问题、模型、采集时间、备注)
|
||||
- `upstream.stream.sse`:完整原始 SSE 文本(`event:` / `data:` 行)
|
||||
|
||||
`meta.json` 的关键字段通常包括:
|
||||
|
||||
- `sample_id`
|
||||
- `captured_at_utc`
|
||||
- `source`
|
||||
- `request`
|
||||
- `capture`
|
||||
|
||||
对于多轮样本,`capture.rounds` 会记录每一轮上游请求,例如首轮 `deepseek_completion` 和后续 `deepseek_continue`。
|
||||
|
||||
## 回放与对比
|
||||
|
||||
回放工具会读取 `upstream.stream.sse`,在本地自动生成当前解析结果,并把派生结果写到 `artifacts/raw-stream-sim/<run-id>/<sample-id>/`,例如:
|
||||
@@ -49,6 +76,23 @@ POST /admin/dev/raw-samples/capture
|
||||
./tests/scripts/run-raw-stream-sim.sh
|
||||
```
|
||||
|
||||
运行**全部样本目录**(不只 manifest 默认样本),并逐个打印 token 对齐结果:
|
||||
|
||||
```bash
|
||||
for d in tests/raw_stream_samples/*; do
|
||||
[ -d "$d" ] || continue
|
||||
sid="$(basename "$d")"
|
||||
[ -f "$d/upstream.stream.sse" ] || continue
|
||||
node tests/tools/deepseek-sse-simulator.mjs --samples-root tests/raw_stream_samples --sample-id "$sid"
|
||||
done
|
||||
```
|
||||
|
||||
回放输出会显示 `tokens=<parsed>/<expected>`,并在不一致时判定失败;`report.json` 中也会包含:
|
||||
|
||||
- `raw_expected_output_tokens`
|
||||
- `raw_parsed_output_tokens`
|
||||
- `raw_token_mismatch`
|
||||
|
||||
运行单个样本并和已有基线比对:
|
||||
|
||||
```bash
|
||||
@@ -60,7 +104,7 @@ POST /admin/dev/raw-samples/capture
|
||||
## 扩展方式
|
||||
|
||||
1. 抓取一次真实请求。
|
||||
2. 直接调用 `/admin/dev/raw-samples/capture`,或者手工新建 `<sample-id>/` 目录并放入 `meta.json` + `upstream.stream.sse`。
|
||||
2. 直接调用 `/admin/dev/raw-samples/capture`,或者先用 `/admin/dev/raw-samples/query` + `/admin/dev/raw-samples/save` 从内存抓包落盘;也可以手工新建 `<sample-id>/` 目录并放入 `meta.json` + `upstream.stream.sse`。
|
||||
3. 运行回放工具或对比脚本,生成本地派生结果并检查是否回归。
|
||||
|
||||
> 注意:样本可能包含搜索结果正文与引用信息,请勿放入敏感账号/密钥。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user