mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-15 21:55:09 +08:00
Merge branch 'CJackHwang:main' into main
This commit is contained in:
1
.github/workflows/release-artifacts.yml
vendored
1
.github/workflows/release-artifacts.yml
vendored
@@ -47,7 +47,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Release Blocking Gates
|
- name: Release Blocking Gates
|
||||||
run: |
|
run: |
|
||||||
./tests/scripts/check-stage6-manual-smoke.sh
|
|
||||||
./tests/scripts/check-refactor-line-gate.sh
|
./tests/scripts/check-refactor-line-gate.sh
|
||||||
./tests/scripts/run-unit-all.sh
|
./tests/scripts/run-unit-all.sh
|
||||||
|
|
||||||
|
|||||||
26
API.en.md
26
API.en.md
@@ -33,6 +33,8 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
|
|||||||
| Health probes | `GET /healthz`, `GET /readyz` |
|
| Health probes | `GET /healthz`, `GET /readyz` |
|
||||||
| CORS | Enabled (uniformly covers `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*`; echoes the browser `Origin` when present, otherwise `*`; default allow-list includes `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`, and also accepts third-party preflight-requested headers such as `x-stainless-*`; `/v1/chat/completions` on Vercel Node Runtime matches the same behavior; internal-only `X-Ds2-Internal-Token` remains blocked) |
|
| CORS | Enabled (uniformly covers `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, and `/admin/*`; echoes the browser `Origin` when present, otherwise `*`; default allow-list includes `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`, and also accepts third-party preflight-requested headers such as `x-stainless-*`; `/v1/chat/completions` on Vercel Node Runtime matches the same behavior; internal-only `X-Ds2-Internal-Token` remains blocked) |
|
||||||
|
|
||||||
|
- All JSON request bodies must be valid UTF-8; malformed byte sequences are rejected on ingress with `400 invalid json`.
|
||||||
|
|
||||||
### 3.0 Adapter-Layer Notes
|
### 3.0 Adapter-Layer Notes
|
||||||
|
|
||||||
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
|
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
|
||||||
@@ -81,7 +83,7 @@ Two header formats accepted:
|
|||||||
- Token is in `config.keys` → **Managed account mode**: DS2API auto-selects an account via rotation
|
- Token is in `config.keys` → **Managed account mode**: DS2API auto-selects an account via rotation
|
||||||
- Token is not in `config.keys` → **Direct token mode**: treated as a DeepSeek token directly
|
- Token is not in `config.keys` → **Direct token mode**: treated as a DeepSeek token directly
|
||||||
|
|
||||||
**Optional header**: `X-Ds2-Target-Account: <email_or_mobile>` — Pin a specific managed account.
|
**Optional header**: `X-Ds2-Target-Account: <email_or_mobile>` — Pin a specific managed account; if the target account does not exist or the managed-account queue is exhausted, the request returns `429`, and current responses do not include `Retry-After`. If the account exists but login/refresh fails, the request returns the underlying `401` or upstream error.
|
||||||
Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=` as the caller credential source.
|
Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=` as the caller credential source.
|
||||||
|
|
||||||
### Admin Endpoints (`/admin/*`)
|
### Admin Endpoints (`/admin/*`)
|
||||||
@@ -198,10 +200,15 @@ No auth required. Returns the currently supported DeepSeek native model list.
|
|||||||
"object": "list",
|
"object": "list",
|
||||||
"data": [
|
"data": [
|
||||||
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
|
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
{"id": "deepseek-v4-pro", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
|
{"id": "deepseek-v4-pro-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
|
{"id": "deepseek-v4-flash-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
{"id": "deepseek-v4-pro-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
{"id": "deepseek-v4-pro-search-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
|
{"id": "deepseek-v4-vision", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
|
||||||
|
{"id": "deepseek-v4-vision-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -300,7 +307,7 @@ data: [DONE]
|
|||||||
- When thinking is enabled, the stream may emit `delta.reasoning_content`
|
- When thinking is enabled, the stream may emit `delta.reasoning_content`
|
||||||
- Text emits `delta.content`
|
- Text emits `delta.content`
|
||||||
- Last chunk includes `finish_reason` and `usage`
|
- Last chunk includes `finish_reason` and `usage`
|
||||||
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent
|
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent. Failed/interrupted endings (for example `response.failed`) may not include `usage`
|
||||||
|
|
||||||
#### Tool Calls
|
#### Tool Calls
|
||||||
|
|
||||||
@@ -416,7 +423,7 @@ Business auth required. Returns OpenAI-compatible embeddings shape.
|
|||||||
| `model` | string | ✅ | Supports native models + alias mapping |
|
| `model` | string | ✅ | Supports native models + alias mapping |
|
||||||
| `input` | string/array | ✅ | Supports string, string array, token array |
|
| `input` | string/array | ✅ | Supports string, string array, token array |
|
||||||
|
|
||||||
> Requires `embeddings.provider`. Current supported values: `mock` / `deterministic` / `builtin`. If missing/unsupported, returns standard error shape with HTTP 501.
|
> Requires `embeddings.provider`. Current supported values: `mock` / `deterministic` / `builtin` (all three use the same local deterministic implementation). If missing/unsupported, returns standard error shape with HTTP 501.
|
||||||
|
|
||||||
### `POST /v1/files`
|
### `POST /v1/files`
|
||||||
|
|
||||||
@@ -430,7 +437,7 @@ Business auth required. OpenAI Files-compatible upload endpoint; currently only
|
|||||||
Constraints and behavior:
|
Constraints and behavior:
|
||||||
|
|
||||||
- `Content-Type` must be `multipart/form-data` (otherwise `400`).
|
- `Content-Type` must be `multipart/form-data` (otherwise `400`).
|
||||||
- Total request size limit is `100 MiB` (over-limit returns `413`).
|
- Total request size limit is **100 MiB** (over-limit returns `413`).
|
||||||
- Success returns an OpenAI `file` object (`id/object/bytes/filename/purpose/status`, etc.) and includes `account_id` for source-account tracing.
|
- Success returns an OpenAI `file` object (`id/object/bytes/filename/purpose/status`, etc.) and includes `account_id` for source-account tracing.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -484,6 +491,13 @@ anthropic-version: 2023-06-01
|
|||||||
| `stream` | boolean | ❌ | Default `false` |
|
| `stream` | boolean | ❌ | Default `false` |
|
||||||
| `system` | string | ❌ | Optional system prompt |
|
| `system` | string | ❌ | Optional system prompt |
|
||||||
| `tools` | array | ❌ | Claude tool schema |
|
| `tools` | array | ❌ | Claude tool schema |
|
||||||
|
| `thinking` | object | ❌ | Anthropic thinking config; translated into downstream reasoning control, and ignored by `-nothinking` models |
|
||||||
|
| `temperature` | number | ❌ | Passed through to the downstream bridge; if `temperature` and `top_p` are both present, `temperature` wins |
|
||||||
|
| `top_p` | number | ❌ | Passed through when `temperature` is absent |
|
||||||
|
| `stop_sequences` | array | ❌ | Passed through as downstream stop sequences |
|
||||||
|
| `tool_choice` | string/object | ❌ | Supports `auto` / `none` / `required` / `{"type":"function","name":"..."}` and is translated to downstream tool choice |
|
||||||
|
|
||||||
|
> Note: `thinking`, `temperature`, `top_p`, `stop_sequences`, and `tool_choice` are translated through the compatibility bridge. Final behavior still depends on the selected model and upstream support. When both `temperature` and `top_p` are present, `temperature` takes precedence.
|
||||||
|
|
||||||
#### Non-Stream Response
|
#### Non-Stream Response
|
||||||
|
|
||||||
@@ -1212,7 +1226,7 @@ Clients should handle HTTP status code plus `error` / `detail` fields.
|
|||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `401` | Authentication failed (invalid key/token, or expired admin JWT) |
|
| `401` | Authentication failed (invalid key/token, or expired admin JWT) |
|
||||||
| `429` | Too many requests (exceeded inflight + queue capacity) |
|
| `429` | Too many requests (exceeded inflight + queue capacity; current responses do not include `Retry-After`) |
|
||||||
| `503` | Model unavailable or upstream error |
|
| `503` | Model unavailable or upstream error |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
19
API.md
19
API.md
@@ -33,6 +33,8 @@
|
|||||||
| 健康检查 | `GET /healthz`、`GET /readyz` |
|
| 健康检查 | `GET /healthz`、`GET /readyz` |
|
||||||
| CORS | 已启用(统一覆盖 `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*`;浏览器有 `Origin` 时回显该 Origin,否则为 `*`;默认允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`,并会放行预检里声明的第三方请求头,如 `x-stainless-*`;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同行为;内部专用头 `X-Ds2-Internal-Token` 仍被拦截) |
|
| CORS | 已启用(统一覆盖 `/v1/*`、`/anthropic/*`、`/v1beta/models/*`、`/admin/*`;浏览器有 `Origin` 时回显该 Origin,否则为 `*`;默认允许 `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`,并会放行预检里声明的第三方请求头,如 `x-stainless-*`;Vercel 上 `/v1/chat/completions` 的 Node Runtime 也对齐相同行为;内部专用头 `X-Ds2-Internal-Token` 仍被拦截) |
|
||||||
|
|
||||||
|
- 所有 JSON 请求体都必须是合法 UTF-8;非法字节序列会在入站阶段被拒绝为 `400 invalid json`。
|
||||||
|
|
||||||
### 3.0 接口适配层说明
|
### 3.0 接口适配层说明
|
||||||
|
|
||||||
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
|
- OpenAI / Claude / Gemini 三套协议已统一挂在同一 `chi` 路由树上,由 `internal/server/router.go` 负责装配。
|
||||||
@@ -81,7 +83,7 @@ Vercel 一键部署可先只填 `DS2API_ADMIN_KEY`,部署后在 `/admin` 导
|
|||||||
- token 在 `config.keys` 中 → **托管账号模式**,自动轮询选择账号
|
- token 在 `config.keys` 中 → **托管账号模式**,自动轮询选择账号
|
||||||
- token 不在 `config.keys` 中 → **直通 token 模式**,直接作为 DeepSeek token 使用
|
- token 不在 `config.keys` 中 → **直通 token 模式**,直接作为 DeepSeek token 使用
|
||||||
|
|
||||||
**可选请求头**:`X-Ds2-Target-Account: <email_or_mobile>` — 指定使用某个托管账号。
|
**可选请求头**:`X-Ds2-Target-Account: <email_or_mobile>` — 指定使用某个托管账号;如果目标账号不存在,或管理账号队列已耗尽,相关业务请求会返回 `429`,当前不会附带 `Retry-After` 头。若账号存在但登录/刷新失败,则返回对应的 `401` 或上游错误。
|
||||||
Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` 作为凭据来源。
|
Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` 作为凭据来源。
|
||||||
|
|
||||||
### Admin 接口(`/admin/*`)
|
### Admin 接口(`/admin/*`)
|
||||||
@@ -307,7 +309,7 @@ data: [DONE]
|
|||||||
- 开启 thinking 时会输出 `delta.reasoning_content`
|
- 开启 thinking 时会输出 `delta.reasoning_content`
|
||||||
- 普通文本输出 `delta.content`
|
- 普通文本输出 `delta.content`
|
||||||
- 最后一段包含 `finish_reason` 和 `usage`
|
- 最后一段包含 `finish_reason` 和 `usage`
|
||||||
- token 计数优先透传上游 DeepSeek SSE(如 `accumulated_token_usage` / `token_usage`);仅在上游缺失时回退本地估算
|
- token 计数优先透传上游 DeepSeek SSE(如 `accumulated_token_usage` / `token_usage`);仅在上游缺失时回退本地估算。失败/中断型结束(例如 `response.failed`)可能不会携带 `usage`
|
||||||
|
|
||||||
#### Tool Calls
|
#### Tool Calls
|
||||||
|
|
||||||
@@ -424,7 +426,7 @@ data: [DONE]
|
|||||||
| `model` | string | ✅ | 支持原生模型 + alias 自动映射 |
|
| `model` | string | ✅ | 支持原生模型 + alias 自动映射 |
|
||||||
| `input` | string/array | ✅ | 支持字符串、字符串数组、token 数组 |
|
| `input` | string/array | ✅ | 支持字符串、字符串数组、token 数组 |
|
||||||
|
|
||||||
> 需配置 `embeddings.provider`。当前支持:`mock` / `deterministic` / `builtin`。未配置或不支持时返回标准错误结构(HTTP 501)。
|
> 需配置 `embeddings.provider`。当前支持:`mock` / `deterministic` / `builtin`(三者都走同一套本地确定性实现)。未配置或不支持时返回标准错误结构(HTTP 501)。
|
||||||
|
|
||||||
### `POST /v1/files`
|
### `POST /v1/files`
|
||||||
|
|
||||||
@@ -438,7 +440,7 @@ data: [DONE]
|
|||||||
约束与行为:
|
约束与行为:
|
||||||
|
|
||||||
- 请求必须为 `multipart/form-data`,否则返回 `400`。
|
- 请求必须为 `multipart/form-data`,否则返回 `400`。
|
||||||
- 请求体总大小上限 `100 MiB`(超限返回 `413`)。
|
- 请求体总大小上限 **100 MiB**(超限返回 `413`)。
|
||||||
- 成功返回 OpenAI `file` 对象(`id/object/bytes/filename/purpose/status` 等字段),并附带 `account_id` 便于定位来源账号。
|
- 成功返回 OpenAI `file` 对象(`id/object/bytes/filename/purpose/status` 等字段),并附带 `account_id` 便于定位来源账号。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -495,6 +497,13 @@ anthropic-version: 2023-06-01
|
|||||||
| `stream` | boolean | ❌ | 默认 `false` |
|
| `stream` | boolean | ❌ | 默认 `false` |
|
||||||
| `system` | string | ❌ | 可选系统提示 |
|
| `system` | string | ❌ | 可选系统提示 |
|
||||||
| `tools` | array | ❌ | Claude tool 定义 |
|
| `tools` | array | ❌ | Claude tool 定义 |
|
||||||
|
| `thinking` | object | ❌ | Anthropic thinking 配置;会转译为下游 reasoning 控制,`-nothinking` 模型会忽略 |
|
||||||
|
| `temperature` | number | ❌ | 透传到下游;若同时提供 `top_p`,以 `temperature` 为准 |
|
||||||
|
| `top_p` | number | ❌ | 当未提供 `temperature` 时透传到下游 |
|
||||||
|
| `stop_sequences` | array | ❌ | 透传到下游停用序列 |
|
||||||
|
| `tool_choice` | string/object | ❌ | 支持 `auto` / `none` / `required` / `{"type":"function","name":"..."}`,并会转译为下游工具选择 |
|
||||||
|
|
||||||
|
> 说明:上述 `thinking`、`temperature`、`top_p`、`stop_sequences`、`tool_choice` 都会走兼容层转译;最终是否生效仍取决于当前模型和上游能力。`temperature` 与 `top_p` 同时存在时,`temperature` 优先。
|
||||||
|
|
||||||
#### 非流式响应
|
#### 非流式响应
|
||||||
|
|
||||||
@@ -1226,7 +1235,7 @@ Gemini 路由使用 Google 风格错误结构:
|
|||||||
| 状态码 | 说明 |
|
| 状态码 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `401` | 鉴权失败(key/token 无效,或 Admin JWT 过期) |
|
| `401` | 鉴权失败(key/token 无效,或 Admin JWT 过期) |
|
||||||
| `429` | 请求过多(超出并发上限 + 等待队列) |
|
| `429` | 请求过多(超出并发上限 + 等待队列;当前不附带 `Retry-After` 头) |
|
||||||
| `503` | 模型不可用或上游服务异常 |
|
| `503` | 模型不可用或上游服务异常 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
17
README.MD
17
README.MD
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
语言 / Language: [中文](README.MD) | [English](README.en.md)
|
语言 / Language: [中文](README.MD) | [English](README.en.md)
|
||||||
|
|
||||||
将 DeepSeek Web 对话能力转换为 OpenAI、Claude 与 Gemini 兼容 API。后端为 **Go 全量实现**,前端为 React WebUI 管理台(源码在 `webui/`,部署时自动构建到 `static/admin`)。
|
将 DeepSeek Web 对话能力转换为 OpenAI、Claude 与 Gemini 兼容 API。核心后端以 **Go** 实现,Vercel 流式桥接额外使用少量 Node Runtime,前端为 React WebUI 管理台(源码在 `webui/`,部署时自动构建到 `static/admin`)。
|
||||||
|
|
||||||
文档入口:[文档导航](docs/README.md) / [架构说明](docs/ARCHITECTURE.md) / [接口文档](API.md)
|
文档入口:[文档导航](docs/README.md) / [架构说明](docs/ARCHITECTURE.md) / [接口文档](API.md)
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ base64 < config.json | tr -d '\n'
|
|||||||
|
|
||||||
### 方式四:本地源码运行
|
### 方式四:本地源码运行
|
||||||
|
|
||||||
**前置要求**:Go 1.26+,Node.js `20.19+` 或 `22.12+`(仅在需要构建 WebUI 时)
|
**前置要求**:Go 1.26+,Node.js `20.19+` 或 `22.12+`(仅在需要构建 WebUI 时);同时确保 `npm` 可用,建议 `npm 10+`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆仓库
|
# 1. 克隆仓库
|
||||||
@@ -308,7 +308,7 @@ go run ./cmd/ds2api
|
|||||||
|
|
||||||
服务实际绑定:`0.0.0.0:5001`,因此同一局域网设备通常也可以通过你的内网 IP 访问。
|
服务实际绑定:`0.0.0.0:5001`,因此同一局域网设备通常也可以通过你的内网 IP 访问。
|
||||||
|
|
||||||
> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js)。你也可以手动构建:`./scripts/build-webui.sh`
|
> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js 和 npm)。你也可以手动构建:`./scripts/build-webui.sh`
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ go run ./cmd/ds2api
|
|||||||
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
|
- `runtime`:账号并发、队列与 token 刷新策略,可通过 Admin Settings 热更新。
|
||||||
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
|
||||||
- `history_split`:旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置。
|
- `history_split`:旧轮次拆分字段,已废弃并忽略,仅保留兼容旧配置。
|
||||||
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为 `history.txt` 上下文文件。
|
- `current_input_file`:唯一生效的独立拆分策略;默认开启且阈值为 `0`,触发时将完整上下文合并上传为 `DS2API_HISTORY.txt` 上下文文件。
|
||||||
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
|
- 如果关闭 `current_input_file`,请求会直接透传,不上传拆分上下文文件。
|
||||||
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。
|
- `thinking_injection`:默认开启;在最新 user 消息末尾追加思考增强提示词,提高高强度推理与工具调用前的思考稳定性;`prompt` 留空时使用内置默认提示词。
|
||||||
|
|
||||||
@@ -338,6 +338,7 @@ go run ./cmd/ds2api
|
|||||||
| **直通 token 模式** | 传入 token 不在 `config.keys` 中时,直接作为 DeepSeek token 使用 |
|
| **直通 token 模式** | 传入 token 不在 `config.keys` 中时,直接作为 DeepSeek token 使用 |
|
||||||
|
|
||||||
可选请求头 `X-Ds2-Target-Account`:指定使用某个托管账号(值为 email 或 mobile)。
|
可选请求头 `X-Ds2-Target-Account`:指定使用某个托管账号(值为 email 或 mobile)。
|
||||||
|
如果指定账号不存在,或者当前管理账号队列已满,请求会返回 `429`;当前 `429` 不附带 `Retry-After` 头。若账号存在但登录/刷新失败,则返回对应的鉴权错误。
|
||||||
Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `?key=` / `?api_key=` 作为调用方凭据。
|
Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `?key=` / `?api_key=` 作为调用方凭据。
|
||||||
|
|
||||||
## 并发模型
|
## 并发模型
|
||||||
@@ -350,7 +351,7 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 `
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 当 in-flight 槽位满时,请求进入等待队列,**不会立即 429**
|
- 当 in-flight 槽位满时,请求进入等待队列,**不会立即 429**
|
||||||
- 超出总承载上限后才返回 `429 Too Many Requests`
|
- 超出总承载上限后才返回 `429 Too Many Requests`,当前响应不附带 `Retry-After`
|
||||||
- `GET /admin/queue/status` 返回实时并发状态
|
- `GET /admin/queue/status` 返回实时并发状态
|
||||||
|
|
||||||
## Tool Call 适配
|
## Tool Call 适配
|
||||||
@@ -428,10 +429,10 @@ npm run build --prefix webui
|
|||||||
|
|
||||||
工作流文件:`.github/workflows/release-artifacts.yml`
|
工作流文件:`.github/workflows/release-artifacts.yml`
|
||||||
|
|
||||||
- **触发条件**:仅在 GitHub Release `published` 时触发(普通 push 不会触发)
|
- **触发条件**:默认仅在 GitHub Release `published` 时自动触发;也支持在 Actions 页面手动 `workflow_dispatch`,并填写 `release_tag` 复跑/补发
|
||||||
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`linux/armv7`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`、`windows/arm64`)+ `sha256sums.txt`
|
- **构建产物**:多平台二进制包(`linux/amd64`、`linux/arm64`、`linux/armv7`、`darwin/amd64`、`darwin/arm64`、`windows/amd64`、`windows/arm64`)、Linux Docker 镜像导出包 + `sha256sums.txt`
|
||||||
- **容器镜像发布**:仅推送到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
- **容器镜像发布**:仅推送到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
||||||
- **每个压缩包包含**:`ds2api` 可执行文件、`static/admin`、WASM 文件(同时支持内置 fallback)、`config.example.json` 配置示例、README、LICENSE
|
- **每个二进制压缩包包含**:`ds2api` 可执行文件、`static/admin`、`config.example.json`、`.env.example`、`README.MD`、`README.en.md`、`LICENSE`
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
|
|||||||
10
README.en.md
10
README.en.md
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
Language: [中文](README.MD) | [English](README.en.md)
|
Language: [中文](README.MD) | [English](README.en.md)
|
||||||
|
|
||||||
DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-compatible, and Gemini-compatible APIs. The backend is a **pure Go implementation**, with a React WebUI admin panel (source in `webui/`, build output auto-generated to `static/admin` during deployment).
|
DS2API converts DeepSeek Web chat capability into OpenAI-compatible, Claude-compatible, and Gemini-compatible APIs. The core backend is Go-based, with a small Node Runtime bridge used for Vercel streaming, and the React WebUI admin panel lives in `webui/` (build output auto-generated to `static/admin` during deployment).
|
||||||
|
|
||||||
Documentation entry: [Docs Index](docs/README.md) / [Architecture](docs/ARCHITECTURE.en.md) / [API Reference](API.en.md)
|
Documentation entry: [Docs Index](docs/README.md) / [Architecture](docs/ARCHITECTURE.en.md) / [API Reference](API.en.md)
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ Common fields:
|
|||||||
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
- `runtime`: account concurrency, queueing, and token refresh behavior, hot-reloadable via Admin Settings.
|
||||||
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
- `auto_delete.mode`: remote session cleanup after each request, supporting `none` / `single` / `all`.
|
||||||
- `history_split`: legacy multi-turn history split field, now ignored and kept only for backward-compatible config loading.
|
- `history_split`: legacy multi-turn history split field, now ignored and kept only for backward-compatible config loading.
|
||||||
- `current_input_file`: the only active split mode; it is enabled by default and uploads the full context as a `history.txt` context file once the character threshold is reached.
|
- `current_input_file`: the only active split mode; it is enabled by default and uploads the full context as a `DS2API_HISTORY.txt` context file once the character threshold is reached.
|
||||||
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
|
- If you turn off `current_input_file`, requests pass through directly without uploading any split context file.
|
||||||
|
|
||||||
For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication).
|
For the full environment variable list, see [docs/DEPLOY.en.md](docs/DEPLOY.en.md). For auth behavior, see [API.en.md](API.en.md#authentication).
|
||||||
@@ -413,10 +413,10 @@ npm run build --prefix webui
|
|||||||
|
|
||||||
Workflow: `.github/workflows/release-artifacts.yml`
|
Workflow: `.github/workflows/release-artifacts.yml`
|
||||||
|
|
||||||
- **Trigger**: only on GitHub Release `published` (normal pushes do not trigger builds)
|
- **Trigger**: by default only on GitHub Release `published`; you can also run it manually via `workflow_dispatch` and pass `release_tag` to rerun / backfill
|
||||||
- **Outputs**: multi-platform archives (`linux/amd64`, `linux/arm64`, `linux/armv7`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`, `windows/arm64`) + `sha256sums.txt`
|
- **Outputs**: multi-platform binary archives (`linux/amd64`, `linux/arm64`, `linux/armv7`, `darwin/amd64`, `darwin/arm64`, `windows/amd64`, `windows/arm64`), Linux Docker image export tarballs, and `sha256sums.txt`
|
||||||
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
||||||
- **Each archive includes**: `ds2api` executable, `static/admin`, WASM file (with embedded fallback support), `config.example.json`-based config template, README, LICENSE
|
- **Each binary archive includes**: the `ds2api` executable, `static/admin`, `config.example.json`, `.env.example`, `README.MD`, `README.en.md`, and `LICENSE`
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ go run ./cmd/ds2api
|
|||||||
cd webui
|
cd webui
|
||||||
|
|
||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm ci
|
||||||
|
|
||||||
# 3. Start dev server (hot reload)
|
# 3. Start dev server (hot reload)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ go run ./cmd/ds2api
|
|||||||
cd webui
|
cd webui
|
||||||
|
|
||||||
# 2. 安装依赖
|
# 2. 安装依赖
|
||||||
npm install
|
npm ci
|
||||||
|
|
||||||
# 3. 启动开发服务器(热更新)
|
# 3. 启动开发服务器(热更新)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ Use `config.json` as the single source of truth:
|
|||||||
|
|
||||||
Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
|
Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml`
|
||||||
|
|
||||||
- **Trigger**: only on Release `published` (no build on normal push)
|
- **Trigger**: by default only on Release `published`; you can also run it manually via `workflow_dispatch` and pass `release_tag` to rerun / backfill
|
||||||
- **Outputs**: multi-platform binary archives + `sha256sums.txt`
|
- **Outputs**: multi-platform binary archives, Linux Docker image export tarballs, and `sha256sums.txt`
|
||||||
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`)
|
||||||
|
|
||||||
| Platform | Architecture | Format |
|
| Platform | Architecture | Format |
|
||||||
@@ -302,6 +302,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts
|
|||||||
| `VERCEL_TOKEN` | Vercel sync token | — |
|
| `VERCEL_TOKEN` | Vercel sync token | — |
|
||||||
| `VERCEL_PROJECT_ID` | Vercel project ID | — |
|
| `VERCEL_PROJECT_ID` | Vercel project ID | — |
|
||||||
| `VERCEL_TEAM_ID` | Vercel team ID | — |
|
| `VERCEL_TEAM_ID` | Vercel team ID | — |
|
||||||
|
| `DS2API_CHAT_HISTORY_PATH` | Chat history storage path (must be set to `/tmp/chat_history.json` on Vercel, otherwise unavailable due to read-only filesystem) | `data/chat_history.json` |
|
||||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | Deployment protection bypass for internal Node→Go calls | — |
|
| `DS2API_VERCEL_PROTECTION_BYPASS` | Deployment protection bypass for internal Node→Go calls | — |
|
||||||
|
|
||||||
### 3.4 Vercel Architecture
|
### 3.4 Vercel Architecture
|
||||||
@@ -391,6 +392,22 @@ If API responses return Vercel HTML `Authentication Required`:
|
|||||||
- **Option B**: Add `x-vercel-protection-bypass` header to requests
|
- **Option B**: Add `x-vercel-protection-bypass` header to requests
|
||||||
- **Option C**: Set `VERCEL_AUTOMATION_BYPASS_SECRET` (or `DS2API_VERCEL_PROTECTION_BYPASS`) for internal Node→Go calls
|
- **Option C**: Set `VERCEL_AUTOMATION_BYPASS_SECRET` (or `DS2API_VERCEL_PROTECTION_BYPASS`) for internal Node→Go calls
|
||||||
|
|
||||||
|
#### Chat History Unavailable (read-only file system)
|
||||||
|
|
||||||
|
```text
|
||||||
|
create chat history dir: mkdir /var/task/data: read-only file system
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause**: Vercel Serverless functions have a read-only filesystem (`/var/task`). Chat history fails because it cannot create directories there.
|
||||||
|
|
||||||
|
**Fix**: Add the following in Vercel Project Settings → Environment Variables:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DS2API_CHAT_HISTORY_PATH=/tmp/chat_history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp` is the only writable directory in Vercel Serverless. Data is ephemeral (not persisted across cold starts), but the feature works within a single instance lifetime.
|
||||||
|
|
||||||
### 3.6 Build Artifacts Not Committed
|
### 3.6 Build Artifacts Not Committed
|
||||||
|
|
||||||
- `static/admin` directory is not in Git
|
- `static/admin` directory is not in Git
|
||||||
@@ -433,7 +450,7 @@ Or step by step:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd webui
|
cd webui
|
||||||
npm install
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
# Output goes to static/admin/
|
# Output goes to static/admin/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ cp config.example.json config.json
|
|||||||
|
|
||||||
仓库内置 GitHub Actions 工作流:`.github/workflows/release-artifacts.yml`
|
仓库内置 GitHub Actions 工作流:`.github/workflows/release-artifacts.yml`
|
||||||
|
|
||||||
- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建)
|
- **触发条件**:默认仅在 Release `published` 时自动触发;也支持在 Actions 页面手动 `workflow_dispatch`,并填写 `release_tag` 复跑/补发
|
||||||
- **构建产物**:多平台二进制压缩包 + `sha256sums.txt`
|
- **构建产物**:多平台二进制压缩包、Linux Docker 镜像导出包 + `sha256sums.txt`
|
||||||
- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`)
|
||||||
|
|
||||||
| 平台 | 架构 | 文件格式 |
|
| 平台 | 架构 | 文件格式 |
|
||||||
@@ -302,6 +302,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
|
|||||||
| `VERCEL_TOKEN` | Vercel 同步 token | — |
|
| `VERCEL_TOKEN` | Vercel 同步 token | — |
|
||||||
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | — |
|
| `VERCEL_PROJECT_ID` | Vercel 项目 ID | — |
|
||||||
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
|
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
|
||||||
|
| `DS2API_CHAT_HISTORY_PATH` | Chat history 存储路径(Vercel 上必须设为 `/tmp/chat_history.json`,否则因文件系统只读而不可用) | `data/chat_history.json` |
|
||||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | 部署保护绕过密钥(内部 Node→Go 调用) | — |
|
| `DS2API_VERCEL_PROTECTION_BYPASS` | 部署保护绕过密钥(内部 Node→Go 调用) | — |
|
||||||
|
|
||||||
### 3.3 运行时行为配置(通过 Admin API 设置)
|
### 3.3 运行时行为配置(通过 Admin API 设置)
|
||||||
@@ -401,6 +402,22 @@ No Output Directory named "public" found after the Build completed.
|
|||||||
- **方案 B**:请求中添加 `x-vercel-protection-bypass` 头
|
- **方案 B**:请求中添加 `x-vercel-protection-bypass` 头
|
||||||
- **方案 C**:设置 `VERCEL_AUTOMATION_BYPASS_SECRET`(或 `DS2API_VERCEL_PROTECTION_BYPASS`),仅影响内部 Node→Go 调用
|
- **方案 C**:设置 `VERCEL_AUTOMATION_BYPASS_SECRET`(或 `DS2API_VERCEL_PROTECTION_BYPASS`),仅影响内部 Node→Go 调用
|
||||||
|
|
||||||
|
#### Chat History 不可用(read-only file system)
|
||||||
|
|
||||||
|
```text
|
||||||
|
create chat history dir: mkdir /var/task/data: read-only file system
|
||||||
|
```
|
||||||
|
|
||||||
|
**原因**:Vercel Serverless 函数的文件系统(`/var/task`)为只读,chat history 尝试在该路径下创建目录失败。
|
||||||
|
|
||||||
|
**解决**:在 Vercel Project Settings → Environment Variables 中添加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DS2API_CHAT_HISTORY_PATH=/tmp/chat_history.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp` 是 Vercel Serverless 环境中唯一可写的目录。数据在函数冷启动之间不会持久化(ephemeral),但在单个实例生命周期内功能正常。
|
||||||
|
|
||||||
### 3.6 仓库不提交构建产物
|
### 3.6 仓库不提交构建产物
|
||||||
|
|
||||||
- `static/admin` 目录不在 Git 中
|
- `static/admin` 目录不在 Git 中
|
||||||
@@ -443,7 +460,7 @@ go run ./cmd/ds2api
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd webui
|
cd webui
|
||||||
npm install
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
# 产物输出到 static/admin/
|
# 产物输出到 static/admin/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
### 文档维护约定
|
### 文档维护约定
|
||||||
|
|
||||||
- 文档更新必须以实际代码实现为依据:总路由装配看 `internal/server/router.go`,协议/resource 路由看 `internal/httpapi/*/**/routes.go` 与 `internal/httpapi/admin/handler.go`,配置默认值看 `internal/config/*`,模型/alias 看 `internal/config/models.go`,prompt 兼容链路看 `docs/prompt-compatibility.md` 列出的代码入口。
|
- 文档更新必须以实际代码实现为依据:总路由装配看 `internal/server/router.go`,协议/resource 路由看 `internal/httpapi/**/handler*.go` 与 `internal/httpapi/admin/handler.go`,配置默认值看 `internal/config/*`,模型/alias 看 `internal/config/models.go`,prompt 兼容链路看 `docs/prompt-compatibility.md` 列出的代码入口。
|
||||||
- `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。
|
- `README.MD` / `README.en.md`:面向首次接触用户,保留“是什么 + 怎么快速跑起来”。
|
||||||
- `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。
|
- `docs/ARCHITECTURE*.md`:面向开发者,集中维护项目结构、模块职责与调用链。
|
||||||
- `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。
|
- `API*.md`:面向客户端接入者,聚焦接口行为、鉴权和示例。
|
||||||
@@ -53,7 +53,7 @@ Recommended reading order:
|
|||||||
|
|
||||||
### Maintenance conventions
|
### Maintenance conventions
|
||||||
|
|
||||||
- Documentation updates must be grounded in the actual implementation: root routing lives in `internal/server/router.go`, protocol/resource routes live in `internal/httpapi/*/**/routes.go` and `internal/httpapi/admin/handler.go`, config defaults in `internal/config/*`, models/aliases in `internal/config/models.go`, and the prompt compatibility pipeline in the code entrypoints listed by `docs/prompt-compatibility.md`.
|
- Documentation updates must be grounded in the actual implementation: root routing lives in `internal/server/router.go`, protocol/resource routes live in `internal/httpapi/**/handler*.go` and `internal/httpapi/admin/handler.go`, config defaults in `internal/config/*`, models/aliases in `internal/config/models.go`, and the prompt compatibility pipeline in the code entrypoints listed by `docs/prompt-compatibility.md`.
|
||||||
- `README.MD` / `README.en.md`: onboarding-oriented (“what + quick start”).
|
- `README.MD` / `README.en.md`: onboarding-oriented (“what + quick start”).
|
||||||
- `docs/ARCHITECTURE*.md`: developer-oriented source of truth for module boundaries and execution flow.
|
- `docs/ARCHITECTURE*.md`: developer-oriented source of truth for module boundaries and execution flow.
|
||||||
- `API*.md`: integration-oriented behavior/contracts.
|
- `API*.md`: integration-oriented behavior/contracts.
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ npm run build --prefix webui
|
|||||||
./tests/scripts/check-refactor-line-gate.sh
|
./tests/scripts/check-refactor-line-gate.sh
|
||||||
./tests/scripts/check-node-split-syntax.sh
|
./tests/scripts/check-node-split-syntax.sh
|
||||||
./tests/scripts/check-cross-build.sh
|
./tests/scripts/check-cross-build.sh
|
||||||
|
|
||||||
# 历史阶段门禁:阶段 6 手工烟测签字检查(默认读取 plans/stage6-manual-smoke.md)
|
|
||||||
./tests/scripts/check-stage6-manual-smoke.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:`plans/stage6-manual-smoke.md` 已移除,阶段 6 手工烟测不再作为当前 CI 或发布门禁。
|
||||||
|
|
||||||
### 端到端测试 | End-to-End Tests
|
### 端到端测试 | End-to-End Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
|
|||||||
- 普通请求会直接出现在最终 `prompt` 的最新 user block 末尾。
|
- 普通请求会直接出现在最终 `prompt` 的最新 user block 末尾。
|
||||||
- 如果触发 current input file,它会进入完整上下文文件中。
|
- 如果触发 current input file,它会进入完整上下文文件中。
|
||||||
|
|
||||||
|
另外,`MessagesPrepareWithThinking` 还会在最终 prompt 的最前面预置一段固定的 system 级“输出完整性约束(Output integrity guard)”:
|
||||||
|
|
||||||
|
- 如果上游上下文、工具输出或解析后的文本出现乱码、损坏、部分解析、重复或其他畸形片段,不要模仿、不要回显,只输出给用户的正确内容。
|
||||||
|
- 这段约束位于普通 system / tool prompt 之前,因此是当前最终 prompt 里的最高优先级前置指令。
|
||||||
|
|
||||||
### 5.1 角色标记
|
### 5.1 角色标记
|
||||||
|
|
||||||
最终 prompt 使用 DeepSeek 风格角色标记:
|
最终 prompt 使用 DeepSeek 风格角色标记:
|
||||||
@@ -155,7 +160,7 @@ OpenAI Chat / Responses 在标准化后、current input file 之前,会默认
|
|||||||
|
|
||||||
工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。
|
工具调用正例现在优先示范官方 DSML 风格:`<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`。
|
||||||
兼容层仍接受旧式纯 `<tool_calls>` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
|
兼容层仍接受旧式纯 `<tool_calls>` wrapper,但提示词会优先要求模型输出官方 DSML 标签,并强调不能只输出 closing wrapper 而漏掉 opening tag。需要注意:这是“兼容 DSML 外壳,内部仍以 XML 解析语义为准”,不是原生 DSML 全链路实现;DSML 标签会在解析入口归一化回现有 XML 标签后继续走同一套 parser。
|
||||||
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `<b>urgent</b>` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。
|
数组参数使用 `<item>...</item>` 子节点表示;当某个参数体只包含 item 子节点时,Go / Node 解析器会把它还原成数组,避免 `questions` / `options` 这类 schema 中要求 array 的参数被误解析成 `{ "item": ... }` 对象。除此之外,解析器还会回收一些更松散的列表写法,例如 JSON array 字面量或逗号分隔的 JSON 项序列,只要它们足够明确;但 `<item>` 仍然是首选形态。若模型把完整结构化 XML fragment 误包进 CDATA,兼容层会在保护 `content` / `command` 等原文字段的前提下,尝试把非原文字段中的 CDATA XML fragment 还原成 object / array。不过,如果 CDATA 只是单个平面的 XML/HTML 标签,例如 `<b>urgent</b>` 这种行内标记,兼容层会保留原始字符串,不会强行升成 object / array;只有明显表示结构的 CDATA 片段,例如多兄弟节点、嵌套子节点或 `item` 列表,才会触发结构化恢复。
|
||||||
Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行上限;当写文件类工具把很长的 `content` 放在单个 `data:` 行里返回时,非流式收集、流式解析和 auto-continue 透传都会保留完整行,再进入同一套工具解析与序列化流程。
|
Go 侧读取 DeepSeek SSE 时不再依赖 `bufio.Scanner` 的固定 2MiB 单行上限;当写文件类工具把很长的 `content` 放在单个 `data:` 行里返回时,非流式收集、流式解析和 auto-continue 透传都会保留完整行,再进入同一套工具解析与序列化流程。
|
||||||
在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。
|
在 assistant 最终回包阶段,如果某个 tool 参数在声明 schema 中明确是 `string`,兼容层会在把解析后的 `tool_calls` / `function_call` 重新序列化成 OpenAI / Responses / Claude 可见参数前,递归把该路径上的 number / bool / object / array 统一转成字符串;其中 object / array 会压成紧凑 JSON 字符串。这个保护只对 schema 明确声明为 string 的路径生效,不会改写本来就是 `number` / `boolean` / `object` / `array` 的参数。这样可以兼容 DeepSeek 输出了结构化片段、但上游客户端工具 schema 又严格要求字符串参数的场景(例如 `content`、`prompt`、`path`、`taskId` 等)。
|
||||||
工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtime(Claude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object,还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter,避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。
|
工具 schema 的权威来源始终是**当前请求实际携带的 schema**,而不是同名工具在其他 runtime(Claude Code / OpenCode / Codex 等)里的默认印象。兼容层现在会同时兼容 OpenAI 风格 `function.parameters`、直接工具对象上的 `parameters` / `input_schema`、以及 camelCase 的 `inputSchema` / `schema`,并在最终输出阶段按这份请求内 schema 决定是保留 array/object,还是仅对明确声明为 `string` 的路径做字符串化。该规则同样适用于 Claude 的流式收尾和 Vercel Node 流式 tool-call formatter,避免不同 runtime 因 schema shape 差异而出现同名工具参数类型漂移。
|
||||||
@@ -238,6 +243,14 @@ OpenAI 文件相关实现:
|
|||||||
- 文件 ID 收集:
|
- 文件 ID 收集:
|
||||||
[internal/promptcompat/file_refs.go](../internal/promptcompat/file_refs.go)
|
[internal/promptcompat/file_refs.go](../internal/promptcompat/file_refs.go)
|
||||||
|
|
||||||
|
OpenAI 的文件上传现在不再是“只传文件本体”的通用路径,而是会先根据请求里的 `model` 解析出 DeepSeek 的上传类型,并把它透传到上传接口的 `x-model-type`。当前可见的上传类型就是 `default` / `expert` / `vision`,其中 vision 请求上传图片时必须带上 `vision`,否则下游容易退回到仅文本或 OCR 语义。这个模型类型会同时用于:
|
||||||
|
|
||||||
|
- `/v1/files` 这类独立文件上传入口
|
||||||
|
- Chat / Responses 的 inline 图片、附件上传
|
||||||
|
- current input file 触发时生成的 `DS2API_HISTORY.txt` 上下文文件
|
||||||
|
|
||||||
|
也就是说,文件上传和完成请求的 `model_type` 现在是一致的:完成 payload 里仍然是 `model_type`,上传文件则会在 DeepSeek 上传阶段携带同样的模型类型信息。
|
||||||
|
|
||||||
结论:
|
结论:
|
||||||
|
|
||||||
- “systemprompt 文字”在 prompt 里
|
- “systemprompt 文字”在 prompt 里
|
||||||
@@ -249,7 +262,7 @@ OpenAI 文件相关实现:
|
|||||||
|
|
||||||
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。
|
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。
|
||||||
|
|
||||||
- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `history.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `history.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
|
- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `DS2API_HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `DS2API_HISTORY.txt` 的上下文文件。文件内容会先做 OpenAI 消息标准化,再序列化成按轮次编号的 `DS2API_HISTORY.txt` 风格 transcript,带有 `# DS2API_HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;live prompt 中则会给出一个 continuation 语气的 user 消息,引导模型从 `DS2API_HISTORY.txt` 的最新状态继续推进,并直接回答最新请求,避免把任务拉回起点。
|
||||||
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
|
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
|
||||||
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
|
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
|
||||||
- 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。
|
- 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。
|
||||||
@@ -263,11 +276,24 @@ OpenAI 文件相关实现:
|
|||||||
- 旧历史拆分兼容壳:
|
- 旧历史拆分兼容壳:
|
||||||
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
|
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
|
||||||
|
|
||||||
当前输入转文件启用并触发时,上传文件的真实文件名是 `history.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,并直接作为 `history.txt` 的纯文本内容上传(不再注入文件边界标签):
|
当前输入转文件启用并触发时,上传文件的真实文件名是 `DS2API_HISTORY.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再按轮次编号成 `DS2API_HISTORY.txt` 风格的 transcript(不再注入文件边界标签):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[uploaded filename]: history.txt
|
[uploaded filename]: DS2API_HISTORY.txt
|
||||||
<|begin▁of▁sentence|><|System|>...<|User|>...<|Assistant|>...<|Tool|>...<|User|>...
|
# DS2API_HISTORY.txt
|
||||||
|
Prior conversation history and tool progress.
|
||||||
|
|
||||||
|
=== 1. SYSTEM ===
|
||||||
|
...
|
||||||
|
|
||||||
|
=== 2. USER ===
|
||||||
|
...
|
||||||
|
|
||||||
|
=== 3. ASSISTANT ===
|
||||||
|
...
|
||||||
|
|
||||||
|
=== 4. TOOL ===
|
||||||
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`。
|
开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`。
|
||||||
@@ -319,7 +345,7 @@ OpenAI 文件相关实现:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>The current request and prior conversation context have already been provided. Answer the latest user request directly.<|Assistant|>",
|
"prompt": "<|begin▁of▁sentence|><|System|>原 system / developer\n\nYou have access to these tools: ...<|end▁of▁instructions|><|User|>Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.<|Assistant|>",
|
||||||
"ref_file_ids": [
|
"ref_file_ids": [
|
||||||
"file-current-input-ignore",
|
"file-current-input-ignore",
|
||||||
"file-systemprompt",
|
"file-systemprompt",
|
||||||
@@ -334,7 +360,7 @@ OpenAI 文件相关实现:
|
|||||||
|
|
||||||
- 大部分结构化语义被压进 `prompt`
|
- 大部分结构化语义被压进 `prompt`
|
||||||
- 文件保持文件
|
- 文件保持文件
|
||||||
- 需要时把完整上下文拆进 `history.txt` 上下文文件
|
- 需要时把完整上下文拆进 `DS2API_HISTORY.txt` 上下文文件,并按轮次编号成 transcript
|
||||||
|
|
||||||
## 12. 修改时必须同步本文档的场景
|
## 12. 修改时必须同步本文档的场景
|
||||||
|
|
||||||
@@ -347,7 +373,7 @@ OpenAI 文件相关实现:
|
|||||||
- tool result 注入方式变更
|
- tool result 注入方式变更
|
||||||
- tool prompt 模板或 tool_choice 约束变更
|
- tool prompt 模板或 tool_choice 约束变更
|
||||||
- inline 文件上传 / 文件引用收集规则变更
|
- inline 文件上传 / 文件引用收集规则变更
|
||||||
- current input file 触发条件、上传格式、`history.txt` 包装格式变更
|
- current input file 触发条件、上传格式、`DS2API_HISTORY.txt` transcript 结构变更
|
||||||
- 旧 `history_split` 兼容逻辑的读取、忽略或退化行为变更
|
- 旧 `history_split` 兼容逻辑的读取、忽略或退化行为变更
|
||||||
- completion payload 字段语义变更
|
- completion payload 字段语义变更
|
||||||
- Claude / Gemini 对这套统一语义的复用关系变更
|
- Claude / Gemini 对这套统一语义的复用关系变更
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type UploadFileRequest struct {
|
|||||||
Filename string
|
Filename string
|
||||||
ContentType string
|
ContentType string
|
||||||
Purpose string
|
Purpose string
|
||||||
|
ModelType string
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
|
|||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
purpose := strings.TrimSpace(req.Purpose)
|
purpose := strings.TrimSpace(req.Purpose)
|
||||||
|
modelType := strings.ToLower(strings.TrimSpace(req.ModelType))
|
||||||
body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data)
|
body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -64,6 +66,9 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
|
|||||||
"purpose": purpose,
|
"purpose": purpose,
|
||||||
"bytes": len(req.Data),
|
"bytes": len(req.Data),
|
||||||
}
|
}
|
||||||
|
if modelType != "" {
|
||||||
|
capturePayload["model_type"] = modelType
|
||||||
|
}
|
||||||
captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
|
captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload)
|
||||||
attempts := 0
|
attempts := 0
|
||||||
refreshed := false
|
refreshed := false
|
||||||
@@ -81,6 +86,9 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload
|
|||||||
}
|
}
|
||||||
headers := c.authHeaders(a.DeepSeekToken)
|
headers := c.authHeaders(a.DeepSeekToken)
|
||||||
headers["Content-Type"] = contentTypeHeader
|
headers["Content-Type"] = contentTypeHeader
|
||||||
|
if modelType != "" {
|
||||||
|
headers["x-model-type"] = modelType
|
||||||
|
}
|
||||||
headers["x-ds-pow-response"] = powHeader
|
headers["x-ds-pow-response"] = powHeader
|
||||||
headers["x-file-size"] = strconv.Itoa(len(req.Data))
|
headers["x-file-size"] = strconv.Itoa(len(req.Data))
|
||||||
headers["x-thinking-enabled"] = "1"
|
headers["x-thinking-enabled"] = "1"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
|
|||||||
var seenTargetPath string
|
var seenTargetPath string
|
||||||
var seenContentType string
|
var seenContentType string
|
||||||
var seenFileSize string
|
var seenFileSize string
|
||||||
|
var seenModelType string
|
||||||
var seenBody string
|
var seenBody string
|
||||||
call := 0
|
call := 0
|
||||||
client := &Client{
|
client := &Client{
|
||||||
@@ -96,6 +97,7 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
|
|||||||
seenPow = req.Header.Get("x-ds-pow-response")
|
seenPow = req.Header.Get("x-ds-pow-response")
|
||||||
seenContentType = req.Header.Get("Content-Type")
|
seenContentType = req.Header.Get("Content-Type")
|
||||||
seenFileSize = req.Header.Get("x-file-size")
|
seenFileSize = req.Header.Get("x-file-size")
|
||||||
|
seenModelType = req.Header.Get("x-model-type")
|
||||||
seenBody = string(bodyBytes)
|
seenBody = string(bodyBytes)
|
||||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
|
||||||
default:
|
default:
|
||||||
@@ -112,6 +114,7 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
|
|||||||
Filename: "demo.txt",
|
Filename: "demo.txt",
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Purpose: "assistants",
|
Purpose: "assistants",
|
||||||
|
ModelType: "vision",
|
||||||
Data: []byte("hello"),
|
Data: []byte("hello"),
|
||||||
}, 1)
|
}, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +143,9 @@ func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
|
|||||||
if seenFileSize != "5" {
|
if seenFileSize != "5" {
|
||||||
t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
|
t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
|
||||||
}
|
}
|
||||||
|
if seenModelType != "vision" {
|
||||||
|
t.Fatalf("expected x-model-type=vision, got %q", seenModelType)
|
||||||
|
}
|
||||||
if !strings.HasPrefix(seenContentType, "multipart/form-data; boundary=") {
|
if !strings.HasPrefix(seenContentType, "multipart/form-data; boundary=") {
|
||||||
t.Fatalf("expected multipart content type, got %q", seenContentType)
|
t.Fatalf("expected multipart content type, got %q", seenContentType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,6 @@ func toStringSet(in []string) map[string]struct{} {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
KeepAliveTimeout = 5
|
KeepAliveTimeout = 5
|
||||||
StreamIdleTimeout = 90
|
StreamIdleTimeout = 300
|
||||||
MaxKeepaliveCount = 10
|
MaxKeepaliveCount = 40
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package claude
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ds2api/internal/config"
|
"ds2api/internal/config"
|
||||||
|
"ds2api/internal/httpapi/requestbody"
|
||||||
streamengine "ds2api/internal/stream"
|
streamengine "ds2api/internal/stream"
|
||||||
"ds2api/internal/translatorcliproxy"
|
"ds2api/internal/translatorcliproxy"
|
||||||
"ds2api/internal/util"
|
"ds2api/internal/util"
|
||||||
@@ -33,7 +35,11 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
|
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
|
||||||
raw, err := io.ReadAll(r.Body)
|
raw, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
||||||
|
writeClaudeError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
} else {
|
||||||
|
writeClaudeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var req map[string]any
|
var req map[string]any
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package gemini
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"ds2api/internal/toolcall"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"ds2api/internal/httpapi/requestbody"
|
||||||
"ds2api/internal/sse"
|
"ds2api/internal/sse"
|
||||||
|
"ds2api/internal/toolcall"
|
||||||
"ds2api/internal/translatorcliproxy"
|
"ds2api/internal/translatorcliproxy"
|
||||||
"ds2api/internal/util"
|
"ds2api/internal/util"
|
||||||
|
|
||||||
@@ -32,7 +34,11 @@ func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request,
|
|||||||
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
|
||||||
raw, err := io.ReadAll(r.Body)
|
raw, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
|
||||||
|
writeGeminiError(w, http.StatusBadRequest, "invalid json")
|
||||||
|
} else {
|
||||||
|
writeGeminiError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
routeModel := strings.TrimSpace(chi.URLParam(r, "model"))
|
routeModel := strings.TrimSpace(chi.URLParam(r, "model"))
|
||||||
|
|||||||
@@ -311,16 +311,16 @@ func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
if ds.uploadCalls[0].Filename != "history.txt" {
|
if ds.uploadCalls[0].Filename != "DS2API_HISTORY.txt" {
|
||||||
t.Fatalf("expected history.txt upload, got %q", ds.uploadCalls[0].Filename)
|
t.Fatalf("expected DS2API_HISTORY.txt upload, got %q", ds.uploadCalls[0].Filename)
|
||||||
}
|
}
|
||||||
if full.HistoryText != string(ds.uploadCalls[0].Data) {
|
if full.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||||
t.Fatalf("expected uploaded current input file to be persisted in history text")
|
t.Fatalf("expected uploaded current input file to be persisted in history text")
|
||||||
}
|
}
|
||||||
if len(full.Messages) != 1 {
|
if len(full.Messages) != 1 {
|
||||||
t.Fatalf("expected neutral prompt to be the only persisted message, got %#v", full.Messages)
|
t.Fatalf("expected continuation prompt to be the only persisted message, got %#v", full.Messages)
|
||||||
}
|
}
|
||||||
if !strings.Contains(full.Messages[0].Content, "Answer the latest user request directly.") {
|
if !strings.Contains(full.Messages[0].Content, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral prompt to be persisted, got %#v", full.Messages[0])
|
t.Fatalf("expected continuation prompt to be persisted, got %#v", full.Messages[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) {
|
|||||||
s.sendDone()
|
s.sendDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *chatStreamRuntime) markContextCancelled() {
|
||||||
|
s.finalErrorStatus = 499
|
||||||
|
s.finalErrorMessage = "request context cancelled"
|
||||||
|
s.finalErrorCode = string(streamengine.StopReasonContextCancelled)
|
||||||
|
s.finalThinking = s.thinking.String()
|
||||||
|
s.finalText = cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers)
|
||||||
|
s.finalFinishReason = string(streamengine.StopReasonContextCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *chatStreamRuntime) resetStreamToolCallState() {
|
func (s *chatStreamRuntime) resetStreamToolCallState() {
|
||||||
s.streamToolCallIDs = map[int]string{}
|
s.streamToolCallIDs = map[int]string{}
|
||||||
s.streamToolNames = map[int]string{}
|
s.streamToolNames = map[int]string{}
|
||||||
|
|||||||
@@ -247,11 +247,15 @@ func (h *Handler) consumeChatStreamAttempt(r *http.Request, resp *http.Response,
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
OnContextDone: func() {
|
OnContextDone: func() {
|
||||||
|
streamRuntime.markContextCancelled()
|
||||||
if historySession != nil {
|
if historySession != nil {
|
||||||
historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled))
|
historySession.stopped(streamRuntime.thinking.String(), streamRuntime.text.String(), string(streamengine.StopReasonContextCancelled))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if streamRuntime.finalErrorCode == string(streamengine.StopReasonContextCancelled) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
|
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
|
||||||
if terminalWritten {
|
if terminalWritten {
|
||||||
recordChatStreamHistory(streamRuntime, historySession)
|
recordChatStreamHistory(streamRuntime, historySession)
|
||||||
@@ -283,6 +287,10 @@ func logChatStreamTerminal(streamRuntime *chatStreamRuntime, attempts int) {
|
|||||||
if attempts > 0 {
|
if attempts > 0 {
|
||||||
source = "synthetic_retry"
|
source = "synthetic_retry"
|
||||||
}
|
}
|
||||||
|
if streamRuntime.finalErrorCode == string(streamengine.StopReasonContextCancelled) {
|
||||||
|
config.Logger.Info("[openai_empty_retry] terminal cancelled", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "error_code", streamRuntime.finalErrorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
if streamRuntime.finalErrorMessage != "" {
|
if streamRuntime.finalErrorMessage != "" {
|
||||||
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
|
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "chat.completions", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
|
||||||
return
|
return
|
||||||
|
|||||||
85
internal/httpapi/openai/chat/empty_retry_runtime_test.go
Normal file
85
internal/httpapi/openai/chat/empty_retry_runtime_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ds2api/internal/chathistory"
|
||||||
|
"ds2api/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConsumeChatStreamAttemptMarksContextCancelledState(t *testing.T) {
|
||||||
|
historyStore := newTestChatHistoryStore(t)
|
||||||
|
entry, err := historyStore.Start(chathistory.StartParams{
|
||||||
|
CallerID: "caller:test",
|
||||||
|
Model: "deepseek-v4-flash",
|
||||||
|
Stream: true,
|
||||||
|
UserInput: "hello",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start history failed: %v", err)
|
||||||
|
}
|
||||||
|
session := &chatHistorySession{
|
||||||
|
store: historyStore,
|
||||||
|
entryID: entry.ID,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
lastPersist: time.Now(),
|
||||||
|
finalPrompt: "prompt",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
streamRuntime := newChatStreamRuntime(
|
||||||
|
rec,
|
||||||
|
http.NewResponseController(rec),
|
||||||
|
true,
|
||||||
|
"cid-cancelled",
|
||||||
|
time.Now().Unix(),
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"prompt",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
resp := makeOpenAISSEHTTPResponse(
|
||||||
|
`data: {"p":"response/content","v":"hello"}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{}
|
||||||
|
terminalWritten, retryable := h.consumeChatStreamAttempt(req, resp, streamRuntime, "text", false, session, true)
|
||||||
|
if !terminalWritten || retryable {
|
||||||
|
t.Fatalf("expected cancelled attempt to terminate without retry, got terminalWritten=%v retryable=%v", terminalWritten, retryable)
|
||||||
|
}
|
||||||
|
if got, want := streamRuntime.finalErrorCode, string(stream.StopReasonContextCancelled); got != want {
|
||||||
|
t.Fatalf("expected cancelled final error code %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
if streamRuntime.finalErrorMessage == "" {
|
||||||
|
t.Fatalf("expected cancelled final error message to be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := historyStore.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("snapshot failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Items) != 1 {
|
||||||
|
t.Fatalf("expected one history item, got %d", len(snapshot.Items))
|
||||||
|
}
|
||||||
|
full, err := historyStore.Get(snapshot.Items[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get detail failed: %v", err)
|
||||||
|
}
|
||||||
|
if full.Status != "stopped" {
|
||||||
|
t.Fatalf("expected stopped status, got %#v", full)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,8 +130,8 @@ func TestHandleVercelStreamPrepareAppliesCurrentInputFile(t *testing.T) {
|
|||||||
t.Fatalf("expected payload object, got %#v", body["payload"])
|
t.Fatalf("expected payload object, got %#v", body["payload"])
|
||||||
}
|
}
|
||||||
promptText, _ := payload["prompt"].(string)
|
promptText, _ := payload["prompt"].(string)
|
||||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
if !strings.Contains(promptText, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral prompt, got %s", promptText)
|
t.Fatalf("expected continuation prompt, got %s", promptText)
|
||||||
}
|
}
|
||||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||||
t.Fatalf("expected original turns hidden from prompt, got %s", promptText)
|
t.Fatalf("expected original turns hidden from prompt, got %s", promptText)
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ func TestPreprocessInlineFileInputsReplacesDataURLAndCollectsRefFileIDs(t *testi
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected 1 upload, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 upload, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
|
if ds.uploadCalls[0].ModelType != "default" {
|
||||||
|
t.Fatalf("expected default model type when request omits model, got %q", ds.uploadCalls[0].ModelType)
|
||||||
|
}
|
||||||
if ds.lastCtx != ctx {
|
if ds.lastCtx != ctx {
|
||||||
t.Fatalf("expected upload to use request context")
|
t.Fatalf("expected upload to use request context")
|
||||||
}
|
}
|
||||||
@@ -149,7 +152,7 @@ func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) {
|
|||||||
func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
||||||
ds := &inlineUploadDSStub{}
|
ds := &inlineUploadDSStub{}
|
||||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
||||||
reqBody := `{"model":"deepseek-v4-flash","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
|
reqBody := `{"model":"deepseek-v4-vision","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody))
|
||||||
req.Header.Set("Authorization", "Bearer direct-token")
|
req.Header.Set("Authorization", "Bearer direct-token")
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -163,6 +166,9 @@ func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
|
if ds.uploadCalls[0].ModelType != "vision" {
|
||||||
|
t.Fatalf("expected vision model type for vision request, got %q", ds.uploadCalls[0].ModelType)
|
||||||
|
}
|
||||||
if ds.completionReq == nil {
|
if ds.completionReq == nil {
|
||||||
t.Fatal("expected completion payload to be captured")
|
t.Fatal("expected completion payload to be captured")
|
||||||
}
|
}
|
||||||
@@ -177,7 +183,7 @@ func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
|||||||
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
h := &openAITestSurface{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds}
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
registerOpenAITestRoutes(r, h)
|
registerOpenAITestRoutes(r, h)
|
||||||
reqBody := `{"model":"deepseek-v4-flash","input":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
|
reqBody := `{"model":"deepseek-v4-pro","input":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}`
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody))
|
||||||
req.Header.Set("Authorization", "Bearer direct-token")
|
req.Header.Set("Authorization", "Bearer direct-token")
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -191,6 +197,9 @@ func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) {
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
|
if ds.uploadCalls[0].ModelType != "expert" {
|
||||||
|
t.Fatalf("expected expert model type for pro request, got %q", ds.uploadCalls[0].ModelType)
|
||||||
|
}
|
||||||
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
|
refIDs, _ := ds.completionReq["ref_file_ids"].([]any)
|
||||||
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
|
if len(refIDs) != 1 || refIDs[0] != "file-inline-1" {
|
||||||
t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"])
|
t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"])
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ds2api/internal/auth"
|
"ds2api/internal/auth"
|
||||||
|
"ds2api/internal/config"
|
||||||
dsclient "ds2api/internal/deepseek/client"
|
dsclient "ds2api/internal/deepseek/client"
|
||||||
"ds2api/internal/httpapi/openai/shared"
|
"ds2api/internal/httpapi/openai/shared"
|
||||||
"ds2api/internal/promptcompat"
|
"ds2api/internal/promptcompat"
|
||||||
@@ -42,6 +43,7 @@ type inlineUploadState struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
handler *Handler
|
handler *Handler
|
||||||
auth *auth.RequestAuth
|
auth *auth.RequestAuth
|
||||||
|
modelType string
|
||||||
uploadedByID map[string]string
|
uploadedByID map[string]string
|
||||||
uploadCount int
|
uploadCount int
|
||||||
inlineFileBytes int
|
inlineFileBytes int
|
||||||
@@ -58,10 +60,19 @@ func (h *Handler) PreprocessInlineFileInputs(ctx context.Context, a *auth.Reques
|
|||||||
if h == nil || h.DS == nil || len(req) == 0 {
|
if h == nil || h.DS == nil || len(req) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
modelType := "default"
|
||||||
|
if requestedModel, ok := req["model"].(string); ok {
|
||||||
|
if resolvedModel, ok := config.ResolveModel(h.Store, requestedModel); ok {
|
||||||
|
if resolvedType, ok := config.GetModelType(resolvedModel); ok {
|
||||||
|
modelType = resolvedType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
state := &inlineUploadState{
|
state := &inlineUploadState{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
handler: h,
|
handler: h,
|
||||||
auth: a,
|
auth: a,
|
||||||
|
modelType: modelType,
|
||||||
uploadedByID: map[string]string{},
|
uploadedByID: map[string]string{},
|
||||||
}
|
}
|
||||||
for _, key := range []string{"messages", "input", "attachments"} {
|
for _, key := range []string{"messages", "input", "attachments"} {
|
||||||
@@ -174,6 +185,7 @@ func (s *inlineUploadState) uploadInlineFile(file inlineDecodedFile) (string, er
|
|||||||
result, err := s.handler.DS.UploadFile(s.ctx, s.auth, dsclient.UploadFileRequest{
|
result, err := s.handler.DS.UploadFile(s.ctx, s.auth, dsclient.UploadFileRequest{
|
||||||
Filename: file.Filename,
|
Filename: file.Filename,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
ModelType: s.modelType,
|
||||||
Data: file.Data,
|
Data: file.Data,
|
||||||
}, 3)
|
}, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"ds2api/internal/auth"
|
"ds2api/internal/auth"
|
||||||
"ds2api/internal/chathistory"
|
"ds2api/internal/chathistory"
|
||||||
|
"ds2api/internal/config"
|
||||||
dsclient "ds2api/internal/deepseek/client"
|
dsclient "ds2api/internal/deepseek/client"
|
||||||
"ds2api/internal/httpapi/openai/shared"
|
"ds2api/internal/httpapi/openai/shared"
|
||||||
)
|
)
|
||||||
@@ -66,10 +67,12 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if contentType == "" && len(data) > 0 {
|
if contentType == "" && len(data) > 0 {
|
||||||
contentType = http.DetectContentType(data)
|
contentType = http.DetectContentType(data)
|
||||||
}
|
}
|
||||||
|
modelType := resolveUploadModelType(h.Store, r)
|
||||||
result, err := h.DS.UploadFile(r.Context(), a, dsclient.UploadFileRequest{
|
result, err := h.DS.UploadFile(r.Context(), a, dsclient.UploadFileRequest{
|
||||||
Filename: header.Filename,
|
Filename: header.Filename,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Purpose: strings.TrimSpace(r.FormValue("purpose")),
|
Purpose: strings.TrimSpace(r.FormValue("purpose")),
|
||||||
|
ModelType: modelType,
|
||||||
Data: data,
|
Data: data,
|
||||||
}, 3)
|
}, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,6 +85,32 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
shared.WriteJSON(w, http.StatusOK, buildOpenAIFileObject(result))
|
shared.WriteJSON(w, http.StatusOK, buildOpenAIFileObject(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveUploadModelType(store shared.ConfigReader, r *http.Request) string {
|
||||||
|
for _, candidate := range []string{r.FormValue("model_type"), r.Header.Get("X-Model-Type")} {
|
||||||
|
if modelType := normalizeUploadModelType(candidate); modelType != "" {
|
||||||
|
return modelType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestedModel := strings.TrimSpace(r.FormValue("model"))
|
||||||
|
if requestedModel != "" {
|
||||||
|
if resolvedModel, ok := config.ResolveModel(store, requestedModel); ok {
|
||||||
|
if modelType, ok := config.GetModelType(resolvedModel); ok {
|
||||||
|
return modelType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUploadModelType(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "default", "expert", "vision":
|
||||||
|
return strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildOpenAIFileObject(result *dsclient.UploadFileResult) map[string]any {
|
func buildOpenAIFileObject(result *dsclient.UploadFileResult) map[string]any {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (m *filesRouteDSStub) DeleteAllSessionsForToken(_ context.Context, _ string
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultipartUploadRequest(t *testing.T, purpose string, filename string, data []byte) *http.Request {
|
func newMultipartUploadRequest(t *testing.T, purpose string, filename string, data []byte, model string) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var body bytes.Buffer
|
var body bytes.Buffer
|
||||||
writer := multipart.NewWriter(&body)
|
writer := multipart.NewWriter(&body)
|
||||||
@@ -86,6 +86,11 @@ func newMultipartUploadRequest(t *testing.T, purpose string, filename string, da
|
|||||||
t.Fatalf("write purpose failed: %v", err)
|
t.Fatalf("write purpose failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if model != "" {
|
||||||
|
if err := writer.WriteField("model", model); err != nil {
|
||||||
|
t.Fatalf("write model failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
part, err := writer.CreateFormFile("file", filename)
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create form file failed: %v", err)
|
t.Fatalf("create form file failed: %v", err)
|
||||||
@@ -108,7 +113,7 @@ func TestFilesRouteUploadSuccess(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
registerOpenAITestRoutes(r, h)
|
registerOpenAITestRoutes(r, h)
|
||||||
|
|
||||||
req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world"))
|
req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world"), "deepseek-v4-vision")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
@@ -121,6 +126,9 @@ func TestFilesRouteUploadSuccess(t *testing.T) {
|
|||||||
if ds.lastReq.Purpose != "assistants" {
|
if ds.lastReq.Purpose != "assistants" {
|
||||||
t.Fatalf("expected purpose assistants, got %q", ds.lastReq.Purpose)
|
t.Fatalf("expected purpose assistants, got %q", ds.lastReq.Purpose)
|
||||||
}
|
}
|
||||||
|
if ds.lastReq.ModelType != "vision" {
|
||||||
|
t.Fatalf("expected vision model type, got %q", ds.lastReq.ModelType)
|
||||||
|
}
|
||||||
if string(ds.lastReq.Data) != "hello world" {
|
if string(ds.lastReq.Data) != "hello world" {
|
||||||
t.Fatalf("unexpected uploaded data: %q", string(ds.lastReq.Data))
|
t.Fatalf("unexpected uploaded data: %q", string(ds.lastReq.Data))
|
||||||
}
|
}
|
||||||
@@ -145,7 +153,7 @@ func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
registerOpenAITestRoutes(r, h)
|
registerOpenAITestRoutes(r, h)
|
||||||
|
|
||||||
req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world"))
|
req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world"), "deepseek-v4-vision")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ds2api/internal/auth"
|
"ds2api/internal/auth"
|
||||||
|
"ds2api/internal/config"
|
||||||
dsclient "ds2api/internal/deepseek/client"
|
dsclient "ds2api/internal/deepseek/client"
|
||||||
"ds2api/internal/httpapi/openai/shared"
|
"ds2api/internal/httpapi/openai/shared"
|
||||||
"ds2api/internal/promptcompat"
|
"ds2api/internal/promptcompat"
|
||||||
@@ -35,10 +36,15 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
|
|||||||
if strings.TrimSpace(fileText) == "" {
|
if strings.TrimSpace(fileText) == "" {
|
||||||
return stdReq, errors.New("current user input file produced empty transcript")
|
return stdReq, errors.New("current user input file produced empty transcript")
|
||||||
}
|
}
|
||||||
|
modelType := "default"
|
||||||
|
if resolvedType, ok := config.GetModelType(stdReq.ResolvedModel); ok {
|
||||||
|
modelType = resolvedType
|
||||||
|
}
|
||||||
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
|
result, err := s.DS.UploadFile(ctx, a, dsclient.UploadFileRequest{
|
||||||
Filename: currentInputFilename,
|
Filename: currentInputFilename,
|
||||||
ContentType: currentInputContentType,
|
ContentType: currentInputContentType,
|
||||||
Purpose: currentInputPurpose,
|
Purpose: currentInputPurpose,
|
||||||
|
ModelType: modelType,
|
||||||
Data: []byte(fileText),
|
Data: []byte(fileText),
|
||||||
}, 3)
|
}, 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +68,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
|
|||||||
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
|
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
|
||||||
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
|
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
|
||||||
// Token accounting must reflect the actual downstream context:
|
// Token accounting must reflect the actual downstream context:
|
||||||
// the uploaded history.txt file content + the neutral live prompt.
|
// the uploaded DS2API_HISTORY.txt file content + the continuation live prompt.
|
||||||
stdReq.PromptTokenText = fileText + "\n" + stdReq.FinalPrompt
|
stdReq.PromptTokenText = fileText + "\n" + stdReq.FinalPrompt
|
||||||
return stdReq, nil
|
return stdReq, nil
|
||||||
}
|
}
|
||||||
@@ -87,5 +93,5 @@ func latestUserInputForFile(messages []any) (int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func currentInputFilePrompt() string {
|
func currentInputFilePrompt() string {
|
||||||
return "The current request and prior conversation context have already been provided. Answer the latest user request directly."
|
return "Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,26 +61,33 @@ func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.Reque
|
|||||||
|
|
||||||
func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {}
|
func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {}
|
||||||
|
|
||||||
func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *testing.T) {
|
func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t *testing.T) {
|
||||||
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
|
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
|
||||||
transcript := buildOpenAICurrentInputContextTranscript(historyMessages)
|
transcript := buildOpenAICurrentInputContextTranscript(historyMessages)
|
||||||
|
|
||||||
if strings.Contains(transcript, "[file content end]") || strings.Contains(transcript, "[file content begin]") || strings.Contains(transcript, "[file name]:") {
|
if strings.Contains(transcript, "[file content end]") || strings.Contains(transcript, "[file content begin]") || strings.Contains(transcript, "[file name]:") {
|
||||||
t.Fatalf("expected plain transcript without file wrapper tags, got %q", transcript)
|
t.Fatalf("expected transcript without file wrapper tags, got %q", transcript)
|
||||||
}
|
}
|
||||||
if !strings.Contains(transcript, "<|begin▁of▁sentence|>") {
|
if !strings.Contains(transcript, "# DS2API_HISTORY.txt") {
|
||||||
t.Fatalf("expected serialized conversation markers, got %q", transcript)
|
t.Fatalf("expected history transcript header, got %q", transcript)
|
||||||
}
|
}
|
||||||
if !strings.Contains(transcript, "first user turn") || !strings.Contains(transcript, "tool result") {
|
if !strings.Contains(transcript, "Prior conversation history and tool progress.") {
|
||||||
t.Fatalf("expected historical turns preserved, got %q", transcript)
|
t.Fatalf("expected history transcript description, got %q", transcript)
|
||||||
}
|
}
|
||||||
if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") {
|
for _, want := range []string{
|
||||||
t.Fatalf("expected reasoning block preserved, got %q", transcript)
|
"=== 1. USER ===",
|
||||||
|
"=== 2. ASSISTANT ===",
|
||||||
|
"=== 3. TOOL ===",
|
||||||
|
"first user turn",
|
||||||
|
"tool result",
|
||||||
|
"[reasoning_content]",
|
||||||
|
"hidden reasoning",
|
||||||
|
"<|DSML|tool_calls>",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(transcript, want) {
|
||||||
|
t.Fatalf("expected transcript to contain %q, got %q", want, transcript)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(transcript, "<|DSML|tool_calls>") {
|
|
||||||
t.Fatalf("expected tool calls preserved, got %q", transcript)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
|
func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
|
||||||
@@ -220,7 +227,7 @@ func TestApplyCurrentInputFileDisabledPassThrough(t *testing.T) {
|
|||||||
DS: ds,
|
DS: ds,
|
||||||
}
|
}
|
||||||
req := map[string]any{
|
req := map[string]any{
|
||||||
"model": "deepseek-v4-flash",
|
"model": "deepseek-v4-vision",
|
||||||
"messages": historySplitTestMessages(),
|
"messages": historySplitTestMessages(),
|
||||||
}
|
}
|
||||||
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
||||||
@@ -243,7 +250,7 @@ func TestApplyCurrentInputFileDisabledPassThrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T) {
|
func TestApplyCurrentInputFileUploadsFirstTurnWithNumberedHistoryTranscript(t *testing.T) {
|
||||||
ds := &inlineUploadDSStub{}
|
ds := &inlineUploadDSStub{}
|
||||||
h := &openAITestSurface{
|
h := &openAITestSurface{
|
||||||
Store: mockOpenAIConfig{
|
Store: mockOpenAIConfig{
|
||||||
@@ -273,15 +280,21 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
|||||||
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
upload := ds.uploadCalls[0]
|
upload := ds.uploadCalls[0]
|
||||||
if upload.Filename != "history.txt" {
|
if upload.Filename != "DS2API_HISTORY.txt" {
|
||||||
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
||||||
}
|
}
|
||||||
uploadedText := string(upload.Data)
|
uploadedText := string(upload.Data)
|
||||||
if strings.Contains(uploadedText, "[file content end]") || strings.Contains(uploadedText, "[file content begin]") || strings.Contains(uploadedText, "[file name]:") {
|
if strings.Contains(uploadedText, "[file content end]") || strings.Contains(uploadedText, "[file content begin]") || strings.Contains(uploadedText, "[file name]:") {
|
||||||
t.Fatalf("expected uploaded transcript without file wrapper tags, got %q", uploadedText)
|
t.Fatalf("expected uploaded transcript without file wrapper tags, got %q", uploadedText)
|
||||||
}
|
}
|
||||||
if !strings.Contains(uploadedText, "<|begin▁of▁sentence|><|User|>first turn content that is long enough") {
|
for _, want := range []string{
|
||||||
t.Fatalf("expected serialized current user turn markers, got %q", uploadedText)
|
"# DS2API_HISTORY.txt",
|
||||||
|
"=== 1. USER ===",
|
||||||
|
"first turn content that is long enough",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(uploadedText, want) {
|
||||||
|
t.Fatalf("expected uploaded transcript to contain %q, got %q", want, uploadedText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(uploadedText, promptcompat.ThinkingInjectionMarker) {
|
if !strings.Contains(uploadedText, promptcompat.ThinkingInjectionMarker) {
|
||||||
t.Fatalf("expected thinking injection in current input file, got %q", uploadedText)
|
t.Fatalf("expected thinking injection in current input file, got %q", uploadedText)
|
||||||
@@ -290,11 +303,11 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
|||||||
if strings.Contains(out.FinalPrompt, "first turn content that is long enough") {
|
if strings.Contains(out.FinalPrompt, "first turn content that is long enough") {
|
||||||
t.Fatalf("expected current input text to be replaced in live prompt, got %s", out.FinalPrompt)
|
t.Fatalf("expected current input text to be replaced in live prompt, got %s", out.FinalPrompt)
|
||||||
}
|
}
|
||||||
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||||
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
|
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
|
if !strings.Contains(out.FinalPrompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral continuation instruction in live prompt, got %s", out.FinalPrompt)
|
t.Fatalf("expected continuation-oriented prompt in live prompt, got %s", out.FinalPrompt)
|
||||||
}
|
}
|
||||||
if len(out.RefFileIDs) != 1 || out.RefFileIDs[0] != "file-inline-1" {
|
if len(out.RefFileIDs) != 1 || out.RefFileIDs[0] != "file-inline-1" {
|
||||||
t.Fatalf("expected current input file id in ref_file_ids, got %#v", out.RefFileIDs)
|
t.Fatalf("expected current input file id in ref_file_ids, got %#v", out.RefFileIDs)
|
||||||
@@ -302,6 +315,9 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
|
|||||||
if !strings.Contains(out.PromptTokenText, "first turn content that is long enough") {
|
if !strings.Contains(out.PromptTokenText, "first turn content that is long enough") {
|
||||||
t.Fatalf("expected prompt token text to preserve original full context, got %q", out.PromptTokenText)
|
t.Fatalf("expected prompt token text to preserve original full context, got %q", out.PromptTokenText)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out.PromptTokenText, "# DS2API_HISTORY.txt") || !strings.Contains(out.PromptTokenText, "=== 1. USER ===") {
|
||||||
|
t.Fatalf("expected prompt token text to include numbered history transcript, got %q", out.PromptTokenText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *testing.T) {
|
func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *testing.T) {
|
||||||
@@ -316,7 +332,7 @@ func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *test
|
|||||||
DS: ds,
|
DS: ds,
|
||||||
}
|
}
|
||||||
req := map[string]any{
|
req := map[string]any{
|
||||||
"model": "deepseek-v4-flash",
|
"model": "deepseek-v4-vision",
|
||||||
"messages": historySplitTestMessages(),
|
"messages": historySplitTestMessages(),
|
||||||
}
|
}
|
||||||
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
||||||
@@ -337,10 +353,13 @@ func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *test
|
|||||||
t.Fatalf("expected prompt token text to contain file context with full conversation, got %q", out.PromptTokenText)
|
t.Fatalf("expected prompt token text to contain file context with full conversation, got %q", out.PromptTokenText)
|
||||||
}
|
}
|
||||||
if strings.Contains(out.PromptTokenText, "[file content end]") || strings.Contains(out.PromptTokenText, "[file name]:") {
|
if strings.Contains(out.PromptTokenText, "[file content end]") || strings.Contains(out.PromptTokenText, "[file name]:") {
|
||||||
t.Fatalf("expected prompt token text to use raw transcript without wrapper tags, got %q", out.PromptTokenText)
|
t.Fatalf("expected prompt token text to omit file wrapper tags, got %q", out.PromptTokenText)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.PromptTokenText, "Answer the latest user request directly.") {
|
if !strings.Contains(out.PromptTokenText, "# DS2API_HISTORY.txt") || !strings.Contains(out.PromptTokenText, "=== 1. SYSTEM ===") {
|
||||||
t.Fatalf("expected prompt token text to also include neutral live prompt, got %q", out.PromptTokenText)
|
t.Fatalf("expected prompt token text to include numbered history transcript, got %q", out.PromptTokenText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.PromptTokenText, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
|
t.Fatalf("expected prompt token text to also include continuation prompt, got %q", out.PromptTokenText)
|
||||||
}
|
}
|
||||||
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") {
|
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") {
|
||||||
t.Fatalf("expected live prompt to hide original turns, got %q", out.FinalPrompt)
|
t.Fatalf("expected live prompt to hide original turns, got %q", out.FinalPrompt)
|
||||||
@@ -359,7 +378,7 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
|||||||
DS: ds,
|
DS: ds,
|
||||||
}
|
}
|
||||||
req := map[string]any{
|
req := map[string]any{
|
||||||
"model": "deepseek-v4-flash",
|
"model": "deepseek-v4-vision",
|
||||||
"messages": historySplitTestMessages(),
|
"messages": historySplitTestMessages(),
|
||||||
}
|
}
|
||||||
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
stdReq, err := promptcompat.NormalizeOpenAIChatRequest(h.Store, req, "")
|
||||||
@@ -378,20 +397,23 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
|
|||||||
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
upload := ds.uploadCalls[0]
|
upload := ds.uploadCalls[0]
|
||||||
if upload.Filename != "history.txt" {
|
if upload.Filename != "DS2API_HISTORY.txt" {
|
||||||
t.Fatalf("expected history.txt upload, got %q", upload.Filename)
|
t.Fatalf("expected DS2API_HISTORY.txt upload, got %q", upload.Filename)
|
||||||
|
}
|
||||||
|
if upload.ModelType != "vision" {
|
||||||
|
t.Fatalf("expected vision model type for vision request, got %q", upload.ModelType)
|
||||||
}
|
}
|
||||||
uploadedText := string(upload.Data)
|
uploadedText := string(upload.Data)
|
||||||
for _, want := range []string{"system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
|
for _, want := range []string{"# DS2API_HISTORY.txt", "=== 1. SYSTEM ===", "=== 2. USER ===", "=== 3. ASSISTANT ===", "=== 4. TOOL ===", "=== 5. USER ===", "system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
|
||||||
if !strings.Contains(uploadedText, want) {
|
if !strings.Contains(uploadedText, want) {
|
||||||
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
|
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
|
||||||
t.Fatalf("expected live prompt to use only a neutral continuation instruction, got %s", out.FinalPrompt)
|
t.Fatalf("expected live prompt to use only a continuation instruction, got %s", out.FinalPrompt)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
|
if !strings.Contains(out.FinalPrompt, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral continuation instruction in live prompt, got %s", out.FinalPrompt)
|
t.Fatalf("expected continuation-oriented prompt in live prompt, got %s", out.FinalPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +445,9 @@ func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) {
|
|||||||
if out.HistoryText != string(ds.uploadCalls[0].Data) {
|
if out.HistoryText != string(ds.uploadCalls[0].Data) {
|
||||||
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
|
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out.HistoryText, "# DS2API_HISTORY.txt") || !strings.Contains(out.HistoryText, "=== 1. SYSTEM ===") {
|
||||||
|
t.Fatalf("expected history text to use numbered transcript format, got %q", out.HistoryText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
|
func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
|
||||||
@@ -454,7 +479,7 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t
|
|||||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
upload := ds.uploadCalls[0]
|
upload := ds.uploadCalls[0]
|
||||||
if upload.Filename != "history.txt" {
|
if upload.Filename != "DS2API_HISTORY.txt" {
|
||||||
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
t.Fatalf("unexpected upload filename: %q", upload.Filename)
|
||||||
}
|
}
|
||||||
if upload.Purpose != "assistants" {
|
if upload.Purpose != "assistants" {
|
||||||
@@ -462,7 +487,10 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t
|
|||||||
}
|
}
|
||||||
historyText := string(upload.Data)
|
historyText := string(upload.Data)
|
||||||
if strings.Contains(historyText, "[file content end]") || strings.Contains(historyText, "[file content begin]") || strings.Contains(historyText, "[file name]:") {
|
if strings.Contains(historyText, "[file content end]") || strings.Contains(historyText, "[file content begin]") || strings.Contains(historyText, "[file name]:") {
|
||||||
t.Fatalf("expected plain history transcript without wrapper tags, got %s", historyText)
|
t.Fatalf("expected history transcript without file wrapper tags, got %s", historyText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(historyText, "# DS2API_HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
|
||||||
|
t.Fatalf("expected history transcript to use numbered sections, got %s", historyText)
|
||||||
}
|
}
|
||||||
if !strings.Contains(historyText, "latest user turn") {
|
if !strings.Contains(historyText, "latest user turn") {
|
||||||
t.Fatalf("expected full context to include latest turn, got %s", historyText)
|
t.Fatalf("expected full context to include latest turn, got %s", historyText)
|
||||||
@@ -471,8 +499,8 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t
|
|||||||
t.Fatal("expected completion payload to be captured")
|
t.Fatal("expected completion payload to be captured")
|
||||||
}
|
}
|
||||||
promptText, _ := ds.completionReq["prompt"].(string)
|
promptText, _ := ds.completionReq["prompt"].(string)
|
||||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
if !strings.Contains(promptText, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral completion prompt, got %s", promptText)
|
t.Fatalf("expected continuation-oriented prompt, got %s", promptText)
|
||||||
}
|
}
|
||||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||||
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
||||||
@@ -523,12 +551,16 @@ func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
|
historyText := string(ds.uploadCalls[0].Data)
|
||||||
|
if !strings.Contains(historyText, "# DS2API_HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
|
||||||
|
t.Fatalf("expected uploaded history text to use numbered transcript format, got %s", historyText)
|
||||||
|
}
|
||||||
if ds.completionReq == nil {
|
if ds.completionReq == nil {
|
||||||
t.Fatal("expected completion payload to be captured")
|
t.Fatal("expected completion payload to be captured")
|
||||||
}
|
}
|
||||||
promptText, _ := ds.completionReq["prompt"].(string)
|
promptText, _ := ds.completionReq["prompt"].(string)
|
||||||
if !strings.Contains(promptText, "Answer the latest user request directly.") {
|
if !strings.Contains(promptText, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") {
|
||||||
t.Fatalf("expected neutral completion prompt, got %s", promptText)
|
t.Fatalf("expected continuation-oriented prompt, got %s", promptText)
|
||||||
}
|
}
|
||||||
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
if strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||||
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
t.Fatalf("expected prompt to hide original turns, got %s", promptText)
|
||||||
@@ -669,11 +701,15 @@ func TestCurrentInputFileWorksAcrossAutoDeleteModes(t *testing.T) {
|
|||||||
if len(ds.uploadCalls) != 1 {
|
if len(ds.uploadCalls) != 1 {
|
||||||
t.Fatalf("expected current input upload for mode=%s, got %d", mode, len(ds.uploadCalls))
|
t.Fatalf("expected current input upload for mode=%s, got %d", mode, len(ds.uploadCalls))
|
||||||
}
|
}
|
||||||
|
historyText := string(ds.uploadCalls[0].Data)
|
||||||
|
if !strings.Contains(historyText, "# DS2API_HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
|
||||||
|
t.Fatalf("expected uploaded history text to use numbered transcript format, got %s", historyText)
|
||||||
|
}
|
||||||
if ds.completionReq == nil {
|
if ds.completionReq == nil {
|
||||||
t.Fatalf("expected completion payload for mode=%s", mode)
|
t.Fatalf("expected completion payload for mode=%s", mode)
|
||||||
}
|
}
|
||||||
promptText, _ := ds.completionReq["prompt"].(string)
|
promptText, _ := ds.completionReq["prompt"].(string)
|
||||||
if !strings.Contains(promptText, "Answer the latest user request directly.") || strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
if !strings.Contains(promptText, "Continue from the latest state in the attached DS2API_HISTORY.txt context.") || strings.Contains(promptText, "first user turn") || strings.Contains(promptText, "latest user turn") {
|
||||||
t.Fatalf("unexpected prompt for mode=%s: %s", mode, promptText)
|
t.Fatalf("unexpected prompt for mode=%s: %s", mode, promptText)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -222,7 +222,13 @@ func (h *Handler) consumeResponsesStreamAttempt(r *http.Request, resp *http.Resp
|
|||||||
finalReason = "content_filter"
|
finalReason = "content_filter"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
OnContextDone: func() {
|
||||||
|
streamRuntime.markContextCancelled()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
if streamRuntime.finalErrorCode == string(streamengine.StopReasonContextCancelled) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
|
terminalWritten := streamRuntime.finalize(finalReason, allowDeferEmpty && finalReason != "content_filter")
|
||||||
if terminalWritten {
|
if terminalWritten {
|
||||||
return true, false
|
return true, false
|
||||||
@@ -235,6 +241,10 @@ func logResponsesStreamTerminal(streamRuntime *responsesStreamRuntime, attempts
|
|||||||
if attempts > 0 {
|
if attempts > 0 {
|
||||||
source = "synthetic_retry"
|
source = "synthetic_retry"
|
||||||
}
|
}
|
||||||
|
if streamRuntime.finalErrorCode == string(streamengine.StopReasonContextCancelled) {
|
||||||
|
config.Logger.Info("[openai_empty_retry] terminal cancelled", "surface", "responses", "stream", true, "retry_attempts", attempts, "error_code", streamRuntime.finalErrorCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
if streamRuntime.failed {
|
if streamRuntime.failed {
|
||||||
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "responses", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
|
config.Logger.Info("[openai_empty_retry] terminal empty output", "surface", "responses", "stream", true, "retry_attempts", attempts, "success_source", "none", "error_code", streamRuntime.finalErrorCode)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package responses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ds2api/internal/promptcompat"
|
||||||
|
"ds2api/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeResponsesOpenAISSEHTTPResponse(lines ...string) *http.Response {
|
||||||
|
body := strings.Join(lines, "\n")
|
||||||
|
if !strings.HasSuffix(body, "\n") {
|
||||||
|
body += "\n"
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeResponsesStreamAttemptMarksContextCancelledState(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
streamRuntime := newResponsesStreamRuntime(
|
||||||
|
rec,
|
||||||
|
http.NewResponseController(rec),
|
||||||
|
true,
|
||||||
|
"resp-cancelled",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"prompt",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
promptcompat.DefaultToolChoicePolicy(),
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
resp := makeResponsesOpenAISSEHTTPResponse(
|
||||||
|
`data: {"p":"response/content","v":"hello"}`,
|
||||||
|
`data: [DONE]`,
|
||||||
|
)
|
||||||
|
|
||||||
|
h := &Handler{}
|
||||||
|
terminalWritten, retryable := h.consumeResponsesStreamAttempt(req, resp, streamRuntime, "text", false, true)
|
||||||
|
if !terminalWritten || retryable {
|
||||||
|
t.Fatalf("expected cancelled attempt to terminate without retry, got terminalWritten=%v retryable=%v", terminalWritten, retryable)
|
||||||
|
}
|
||||||
|
if !streamRuntime.failed {
|
||||||
|
t.Fatalf("expected cancelled response stream to be marked failed")
|
||||||
|
}
|
||||||
|
if got, want := streamRuntime.finalErrorCode, string(stream.StopReasonContextCancelled); got != want {
|
||||||
|
t.Fatalf("expected cancelled final error code %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
if streamRuntime.finalErrorMessage == "" {
|
||||||
|
t.Fatalf("expected cancelled final error message to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,13 @@ func (s *responsesStreamRuntime) failResponse(status int, message, code string)
|
|||||||
s.sendDone()
|
s.sendDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *responsesStreamRuntime) markContextCancelled() {
|
||||||
|
s.failed = true
|
||||||
|
s.finalErrorStatus = 499
|
||||||
|
s.finalErrorMessage = "request context cancelled"
|
||||||
|
s.finalErrorCode = string(streamengine.StopReasonContextCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *responsesStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) bool {
|
func (s *responsesStreamRuntime) finalize(finishReason string, deferEmptyOutput bool) bool {
|
||||||
s.failed = false
|
s.failed = false
|
||||||
s.finalErrorStatus = 0
|
s.finalErrorStatus = 0
|
||||||
|
|||||||
134
internal/httpapi/requestbody/json_utf8.go
Normal file
134
internal/httpapi/requestbody/json_utf8.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package requestbody
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidUTF8Body = errors.New("invalid utf-8 request body")
|
||||||
|
errRequestBodyTooLarge = errors.New("request body too large")
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxJSONUTF8ValidationSize = 100 << 20
|
||||||
|
|
||||||
|
// ValidateJSONUTF8 validates complete JSON request bodies before downstream
|
||||||
|
// decoders can silently replace malformed UTF-8 or stop before trailing bytes.
|
||||||
|
func ValidateJSONUTF8(next http.Handler) http.Handler {
|
||||||
|
if next == nil {
|
||||||
|
return http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if shouldValidateJSONBody(r) {
|
||||||
|
r.Body = validateAndReplayBody(r.Body)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldValidateJSONBody(r *http.Request) bool {
|
||||||
|
if r == nil || r.Body == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
path := ""
|
||||||
|
if r.URL != nil {
|
||||||
|
path = r.URL.Path
|
||||||
|
}
|
||||||
|
return isJSONContentType(r.Header.Get("Content-Type")) || isKnownJSONRequestPath(r.Method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSONContentType(raw string) bool {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mediaType, _, err := mime.ParseMediaType(raw)
|
||||||
|
if err != nil {
|
||||||
|
mediaType = raw
|
||||||
|
}
|
||||||
|
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||||
|
return strings.Contains(mediaType, "json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownJSONRequestPath(method, path string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(method)) {
|
||||||
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case path == "/v1/chat/completions" || path == "/chat/completions":
|
||||||
|
return true
|
||||||
|
case path == "/v1/responses" || path == "/responses":
|
||||||
|
return true
|
||||||
|
case path == "/v1/embeddings" || path == "/embeddings":
|
||||||
|
return true
|
||||||
|
case path == "/anthropic/v1/messages" || path == "/v1/messages" || path == "/messages":
|
||||||
|
return true
|
||||||
|
case path == "/anthropic/v1/messages/count_tokens" || path == "/v1/messages/count_tokens" || path == "/messages/count_tokens":
|
||||||
|
return true
|
||||||
|
case strings.HasPrefix(path, "/v1beta/models/") || strings.HasPrefix(path, "/v1/models/"):
|
||||||
|
return strings.Contains(path, ":generateContent") || strings.Contains(path, ":streamGenerateContent")
|
||||||
|
case strings.HasPrefix(path, "/admin/"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndReplayBody(body io.ReadCloser) io.ReadCloser {
|
||||||
|
if body == nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(body, maxJSONUTF8ValidationSize+1))
|
||||||
|
if err != nil {
|
||||||
|
return &errorReadCloser{err: err, closer: body}
|
||||||
|
}
|
||||||
|
if len(raw) > maxJSONUTF8ValidationSize {
|
||||||
|
return &errorReadCloser{err: errRequestBodyTooLarge, closer: body}
|
||||||
|
}
|
||||||
|
if !utf8.Valid(raw) {
|
||||||
|
return &errorReadCloser{err: ErrInvalidUTF8Body, closer: body}
|
||||||
|
}
|
||||||
|
return &replayReadCloser{Reader: bytes.NewReader(raw), closer: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayReadCloser struct {
|
||||||
|
*bytes.Reader
|
||||||
|
closer io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *replayReadCloser) Close() error {
|
||||||
|
if r == nil || r.closer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorReadCloser struct {
|
||||||
|
err error
|
||||||
|
closer io.Closer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *errorReadCloser) Read([]byte) (int, error) {
|
||||||
|
if r == nil || r.err == nil {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *errorReadCloser) Close() error {
|
||||||
|
if r == nil || r.closer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.closer.Close()
|
||||||
|
}
|
||||||
158
internal/httpapi/requestbody/json_utf8_test.go
Normal file
158
internal/httpapi/requestbody/json_utf8_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package requestbody
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type singleByteReadCloser struct {
|
||||||
|
data []byte
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *singleByteReadCloser) Read(p []byte) (int, error) {
|
||||||
|
if r.pos >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
p[0] = r.data[r.pos]
|
||||||
|
r.pos++
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *singleByteReadCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJSONUTF8AllowsSplitMultibyteRunes(t *testing.T) {
|
||||||
|
body := []byte(`{"text":"你好"}`)
|
||||||
|
handler := ValidateJSONUTF8(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Fatalf("unexpected decode error: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", &singleByteReadCloser{data: body})
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204 for valid utf-8 json, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJSONUTF8RejectsInvalidBytesBeforeJSONDecode(t *testing.T) {
|
||||||
|
body := append([]byte(`{"text":"`), 0xff)
|
||||||
|
body = append(body, []byte(`"}`)...)
|
||||||
|
handler := ValidateJSONUTF8(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for invalid utf-8 json, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid utf-8") {
|
||||||
|
t.Fatalf("expected utf-8 validation error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJSONUTF8RejectsInvalidBytesWithoutJSONContentTypeOnKnownPath(t *testing.T) {
|
||||||
|
body := append([]byte(`{"text":"`), 0xff)
|
||||||
|
body = append(body, []byte(`"}`)...)
|
||||||
|
handler := ValidateJSONUTF8(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for invalid utf-8 json, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid utf-8") {
|
||||||
|
t.Fatalf("expected utf-8 validation error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJSONUTF8RejectsTrailingInvalidBytesAfterJSONValue(t *testing.T) {
|
||||||
|
body := append([]byte(`{"text":"ok"}`), 0xff)
|
||||||
|
handler := ValidateJSONUTF8(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for trailing invalid utf-8, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid utf-8") {
|
||||||
|
t.Fatalf("expected utf-8 validation error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsJSONContentType(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
"application/json",
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
"application/problem+json",
|
||||||
|
"application/vnd.api+json",
|
||||||
|
} {
|
||||||
|
if !isJSONContentType(raw) {
|
||||||
|
t.Fatalf("expected %q to be recognized as json", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{
|
||||||
|
"multipart/form-data; boundary=abc",
|
||||||
|
"text/plain",
|
||||||
|
"application/octet-stream",
|
||||||
|
} {
|
||||||
|
if isJSONContentType(raw) {
|
||||||
|
t.Fatalf("expected %q not to be recognized as json", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsKnownJSONRequestPathIncludesGeminiStream(t *testing.T) {
|
||||||
|
if !isKnownJSONRequestPath(http.MethodPost, "/v1beta/models/gemini-pro:streamGenerateContent") {
|
||||||
|
t.Fatal("expected Gemini stream generate path to be recognized as json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -846,10 +846,18 @@ function parseMarkupValue(raw, paramName = '') {
|
|||||||
if (cdata.ok) {
|
if (cdata.ok) {
|
||||||
const literal = parseJSONLiteralValue(cdata.value);
|
const literal = parseJSONLiteralValue(cdata.value);
|
||||||
if (literal.ok) {
|
if (literal.ok) {
|
||||||
|
const literalArray = coerceArrayValue(literal.value, paramName);
|
||||||
|
if (literalArray.ok) {
|
||||||
|
return literalArray.value;
|
||||||
|
}
|
||||||
return literal.value;
|
return literal.value;
|
||||||
}
|
}
|
||||||
const structured = parseStructuredCDATAParameterValue(paramName, cdata.value);
|
const structured = parseStructuredCDATAParameterValue(paramName, cdata.value);
|
||||||
return structured.ok ? structured.value : cdata.value;
|
if (structured.ok) {
|
||||||
|
return structured.value;
|
||||||
|
}
|
||||||
|
const looseArray = parseLooseJSONArrayValue(cdata.value, paramName);
|
||||||
|
return looseArray.ok ? looseArray.value : cdata.value;
|
||||||
}
|
}
|
||||||
const s = toStringSafe(extractRawTagValue(raw)).trim();
|
const s = toStringSafe(extractRawTagValue(raw)).trim();
|
||||||
if (!s) {
|
if (!s) {
|
||||||
@@ -862,8 +870,14 @@ function parseMarkupValue(raw, paramName = '') {
|
|||||||
return nested;
|
return nested;
|
||||||
}
|
}
|
||||||
if (nested && typeof nested === 'object') {
|
if (nested && typeof nested === 'object') {
|
||||||
|
const nestedArray = coerceArrayValue(nested, paramName);
|
||||||
|
if (nestedArray.ok) {
|
||||||
|
return nestedArray.value;
|
||||||
|
}
|
||||||
if (isOnlyRawValue(nested)) {
|
if (isOnlyRawValue(nested)) {
|
||||||
return toStringSafe(nested._raw);
|
const rawValue = toStringSafe(nested._raw);
|
||||||
|
const looseArray = parseLooseJSONArrayValue(rawValue, paramName);
|
||||||
|
return looseArray.ok ? looseArray.value : rawValue;
|
||||||
}
|
}
|
||||||
return nested;
|
return nested;
|
||||||
}
|
}
|
||||||
@@ -871,8 +885,16 @@ function parseMarkupValue(raw, paramName = '') {
|
|||||||
|
|
||||||
const literal = parseJSONLiteralValue(s);
|
const literal = parseJSONLiteralValue(s);
|
||||||
if (literal.ok) {
|
if (literal.ok) {
|
||||||
|
const literalArray = coerceArrayValue(literal.value, paramName);
|
||||||
|
if (literalArray.ok) {
|
||||||
|
return literalArray.value;
|
||||||
|
}
|
||||||
return literal.value;
|
return literal.value;
|
||||||
}
|
}
|
||||||
|
const looseArray = parseLooseJSONArrayValue(s, paramName);
|
||||||
|
if (looseArray.ok) {
|
||||||
|
return looseArray.value;
|
||||||
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,6 +1030,226 @@ function parseJSONLiteralValue(raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLooseJSONArrayValue(raw, paramName = '') {
|
||||||
|
if (preservesCDATAStringParameter(paramName)) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
const s = toStringSafe(raw).trim();
|
||||||
|
if (!s) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
const candidate = parseLooseJSONArrayCandidate(s, paramName);
|
||||||
|
if (candidate.ok) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = splitTopLevelJSONValues(s);
|
||||||
|
if (segments.length < 2) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const segment of segments) {
|
||||||
|
const parsed = parseLooseArrayElementValue(segment);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
out.push(parsed.value);
|
||||||
|
}
|
||||||
|
return { ok: true, value: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLooseJSONArrayCandidate(raw, paramName = '') {
|
||||||
|
const parsed = parseLooseArrayElementValue(raw);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
return coerceArrayValue(parsed.value, paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLooseArrayElementValue(raw) {
|
||||||
|
const s = toStringSafe(raw).trim();
|
||||||
|
if (!s) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const literal = parseJSONLiteralValue(s);
|
||||||
|
if (literal.ok) {
|
||||||
|
return literal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairedBackslashes = repairInvalidJSONBackslashes(s);
|
||||||
|
if (repairedBackslashes !== s) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(repairedBackslashes);
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
} catch (_err) {
|
||||||
|
// Fall through.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repairedLoose = repairLooseJSON(s);
|
||||||
|
if (repairedLoose !== s) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(repairedLoose);
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
} catch (_err) {
|
||||||
|
// Fall through.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.includes('<') && s.includes('>')) {
|
||||||
|
const parsed = parseMarkupInput(s);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceArrayValue(value, paramName = '') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length !== 1) {
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, 'item')) {
|
||||||
|
const items = value.item;
|
||||||
|
const nested = coerceArrayValue(items, '');
|
||||||
|
return nested.ok ? nested : { ok: true, value: [items] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paramName && Object.prototype.hasOwnProperty.call(value, paramName)) {
|
||||||
|
const nested = coerceArrayValue(value[paramName], '');
|
||||||
|
if (nested.ok) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, value: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevelJSONValues(raw) {
|
||||||
|
const s = toStringSafe(raw).trim();
|
||||||
|
if (!s) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = [];
|
||||||
|
let start = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '{' || ch === '[') {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '}' || ch === ']') {
|
||||||
|
if (depth > 0) {
|
||||||
|
depth -= 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ',' && depth === 0) {
|
||||||
|
const segment = s.slice(start, i).trim();
|
||||||
|
if (!segment) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
values.push(segment);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = s.slice(start).trim();
|
||||||
|
if (!last) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
values.push(last);
|
||||||
|
return values.length > 1 ? values : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairInvalidJSONBackslashes(s) {
|
||||||
|
if (!s || !s.includes('\\')) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (ch !== '\\') {
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (i + 1 < s.length) {
|
||||||
|
const next = s[i + 1];
|
||||||
|
if ('"\\/bfnrt'.includes(next)) {
|
||||||
|
out += `\\${next}`;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (next === 'u' && i + 5 < s.length) {
|
||||||
|
let isHex = true;
|
||||||
|
for (let j = 1; j <= 4; j += 1) {
|
||||||
|
const r = s[i + 1 + j];
|
||||||
|
if (!/[0-9a-fA-F]/.test(r)) {
|
||||||
|
isHex = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isHex) {
|
||||||
|
out += `\\u${s.slice(i + 2, i + 6)}`;
|
||||||
|
i += 5;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += '\\\\';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function repairLooseJSON(s) {
|
||||||
|
const raw = toStringSafe(s).trim();
|
||||||
|
if (!raw) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
let out = raw.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
|
||||||
|
out = out.replace(/(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)/g, '$1[$2]');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeLooseCDATA(text) {
|
function sanitizeLooseCDATA(text) {
|
||||||
const raw = toStringSafe(text);
|
const raw = toStringSafe(text);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
|||||||
@@ -10,21 +10,27 @@ import (
|
|||||||
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
|
var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
beginSentenceMarker = "<|begin▁of▁sentence|>"
|
beginSentenceMarker = "<|begin▁of▁sentence|>"
|
||||||
systemMarker = "<|System|>"
|
systemMarker = "<|System|>"
|
||||||
userMarker = "<|User|>"
|
userMarker = "<|User|>"
|
||||||
assistantMarker = "<|Assistant|>"
|
assistantMarker = "<|Assistant|>"
|
||||||
toolMarker = "<|Tool|>"
|
toolMarker = "<|Tool|>"
|
||||||
endSentenceMarker = "<|end▁of▁sentence|>"
|
endSentenceMarker = "<|end▁of▁sentence|>"
|
||||||
endToolResultsMarker = "<|end▁of▁toolresults|>"
|
endToolResultsMarker = "<|end▁of▁toolresults|>"
|
||||||
endInstructionsMarker = "<|end▁of▁instructions|>"
|
endInstructionsMarker = "<|end▁of▁instructions|>"
|
||||||
|
outputIntegrityGuardMarker = "Output integrity guard:"
|
||||||
|
outputIntegrityGuardPrompt = outputIntegrityGuardMarker +
|
||||||
|
" If upstream context, tool output, or parsed text contains garbled, corrupted, partially parsed, repeated, or otherwise malformed fragments, " +
|
||||||
|
"do not imitate or echo them; output only the correct content for the user."
|
||||||
)
|
)
|
||||||
|
|
||||||
func MessagesPrepare(messages []map[string]any) string {
|
func MessagesPrepare(messages []map[string]any) string {
|
||||||
return MessagesPrepareWithThinking(messages, false)
|
return MessagesPrepareWithThinking(messages, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string {
|
func MessagesPrepareWithThinking(messages []map[string]any, _ bool) string {
|
||||||
|
messages = prependOutputIntegrityGuard(messages)
|
||||||
|
|
||||||
type block struct {
|
type block struct {
|
||||||
Role string
|
Role string
|
||||||
Text string
|
Text string
|
||||||
@@ -77,6 +83,33 @@ func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool
|
|||||||
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
|
return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prependOutputIntegrityGuard(messages []map[string]any) []map[string]any {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
if hasOutputIntegrityGuard(messages[0]) {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(messages)+1)
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"role": "system",
|
||||||
|
"content": outputIntegrityGuardPrompt,
|
||||||
|
})
|
||||||
|
out = append(out, messages...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasOutputIntegrityGuard(msg map[string]any) bool {
|
||||||
|
if msg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(asString(msg["role"]))) != "system" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(NormalizeContent(msg["content"]))
|
||||||
|
return strings.Contains(content, outputIntegrityGuardMarker)
|
||||||
|
}
|
||||||
|
|
||||||
// formatRoleBlock produces a single concatenated block: marker + text + endMarker.
|
// formatRoleBlock produces a single concatenated block: marker + text + endMarker.
|
||||||
// No whitespace is inserted between marker and text so role boundaries stay
|
// No whitespace is inserted between marker and text so role boundaries stay
|
||||||
// compact and predictable for downstream parsers.
|
// compact and predictable for downstream parsers.
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) {
|
|||||||
if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") {
|
if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") {
|
||||||
t.Fatalf("expected begin-of-sentence marker, got %q", got)
|
t.Fatalf("expected begin-of-sentence marker, got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<|System|>System rule<|end▁of▁instructions|>") {
|
if !strings.Contains(got, "<|System|>") || !strings.Contains(got, "<|end▁of▁instructions|>") || !strings.Contains(got, "System rule") {
|
||||||
t.Fatalf("expected system instructions suffix, got %q", got)
|
t.Fatalf("expected system instructions to remain present, got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "<|User|>Question") {
|
if !strings.Contains(got, "<|User|>Question") {
|
||||||
t.Fatalf("expected user question, got %q", got)
|
t.Fatalf("expected user question, got %q", got)
|
||||||
@@ -49,6 +49,23 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessagesPreparePrependsOutputIntegrityGuard(t *testing.T) {
|
||||||
|
messages := []map[string]any{
|
||||||
|
{"role": "system", "content": "System rule"},
|
||||||
|
{"role": "user", "content": "Question"},
|
||||||
|
}
|
||||||
|
got := MessagesPrepare(messages)
|
||||||
|
if !strings.HasPrefix(got, beginSentenceMarker+systemMarker+outputIntegrityGuardPrompt) {
|
||||||
|
t.Fatalf("expected output integrity guard to be prepended, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, outputIntegrityGuardPrompt+"\n\nSystem rule") {
|
||||||
|
t.Fatalf("expected output integrity guard to precede system prompt content, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "<|User|>Question") {
|
||||||
|
t.Fatalf("expected user question after guard, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
|
func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) {
|
||||||
got := NormalizeContent([]any{
|
got := NormalizeContent([]any{
|
||||||
map[string]any{"type": "text", "text": "", "content": "from-content"},
|
map[string]any{"type": "text", "text": "", "content": "from-content"},
|
||||||
|
|||||||
@@ -1,35 +1,108 @@
|
|||||||
package promptcompat
|
package promptcompat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ds2api/internal/prompt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const CurrentInputContextFilename = "history.txt"
|
const CurrentInputContextFilename = "DS2API_HISTORY.txt"
|
||||||
|
|
||||||
|
const historyTranscriptTitle = "# DS2API_HISTORY.txt"
|
||||||
|
const historyTranscriptSummary = "Prior conversation history and tool progress."
|
||||||
|
|
||||||
func BuildOpenAIHistoryTranscript(messages []any) string {
|
func BuildOpenAIHistoryTranscript(messages []any) string {
|
||||||
return buildOpenAIInjectedFileTranscript(messages)
|
return buildOpenAIHistoryTranscript(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildOpenAICurrentUserInputTranscript(text string) string {
|
func BuildOpenAICurrentUserInputTranscript(text string) string {
|
||||||
if strings.TrimSpace(text) == "" {
|
if strings.TrimSpace(text) == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return BuildOpenAICurrentInputContextTranscript([]any{
|
return buildOpenAIHistoryTranscript([]any{
|
||||||
map[string]any{"role": "user", "content": text},
|
map[string]any{"role": "user", "content": text},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildOpenAICurrentInputContextTranscript(messages []any) string {
|
func BuildOpenAICurrentInputContextTranscript(messages []any) string {
|
||||||
return buildOpenAIInjectedFileTranscript(messages)
|
return buildOpenAIHistoryTranscript(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOpenAIInjectedFileTranscript(messages []any) string {
|
func buildOpenAIHistoryTranscript(messages []any) string {
|
||||||
normalized := NormalizeOpenAIMessagesForPrompt(messages, "")
|
if len(messages) == 0 {
|
||||||
transcript := strings.TrimSpace(prompt.MessagesPrepare(normalized))
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(historyTranscriptTitle)
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(historyTranscriptSummary)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
entry := 0
|
||||||
|
for _, raw := range messages {
|
||||||
|
msg, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := normalizeOpenAIRoleForPrompt(strings.ToLower(strings.TrimSpace(asString(msg["role"]))))
|
||||||
|
content := strings.TrimSpace(buildOpenAIHistoryEntry(role, msg))
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry++
|
||||||
|
fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript := strings.TrimSpace(b.String())
|
||||||
if transcript == "" {
|
if transcript == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return transcript
|
return transcript + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOpenAIHistoryEntry(role string, msg map[string]any) string {
|
||||||
|
switch role {
|
||||||
|
case "assistant":
|
||||||
|
return strings.TrimSpace(buildAssistantContentForPrompt(msg))
|
||||||
|
case "tool", "function":
|
||||||
|
return strings.TrimSpace(buildToolHistoryContent(msg))
|
||||||
|
case "system", "user":
|
||||||
|
return strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolHistoryContent(msg map[string]any) string {
|
||||||
|
content := strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
|
||||||
|
parts = append(parts, "name="+name)
|
||||||
|
}
|
||||||
|
if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" {
|
||||||
|
parts = append(parts, "tool_call_id="+callID)
|
||||||
|
}
|
||||||
|
header := ""
|
||||||
|
if len(parts) > 0 {
|
||||||
|
header = "[" + strings.Join(parts, " ") + "]"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case header != "" && content != "":
|
||||||
|
return header + "\n" + content
|
||||||
|
case header != "":
|
||||||
|
return header
|
||||||
|
default:
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleLabelForHistory(role string) string {
|
||||||
|
role = strings.ToLower(strings.TrimSpace(role))
|
||||||
|
switch role {
|
||||||
|
case "function":
|
||||||
|
return "tool"
|
||||||
|
case "":
|
||||||
|
return "unknown"
|
||||||
|
default:
|
||||||
|
return role
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,38 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildOpenAIFinalPromptPrependsOutputIntegrityGuard(t *testing.T) {
|
||||||
|
messages := []any{
|
||||||
|
map[string]any{"role": "system", "content": "You are helpful"},
|
||||||
|
map[string]any{"role": "user", "content": "请调用工具"},
|
||||||
|
}
|
||||||
|
tools := []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": "search",
|
||||||
|
"description": "search docs",
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
|
||||||
|
guardIdx := strings.Index(finalPrompt, "Output integrity guard")
|
||||||
|
toolIdx := strings.Index(finalPrompt, "TOOL CALL FORMAT")
|
||||||
|
if guardIdx < 0 {
|
||||||
|
t.Fatalf("expected output integrity guard in final prompt, got: %q", finalPrompt)
|
||||||
|
}
|
||||||
|
if toolIdx < 0 {
|
||||||
|
t.Fatalf("expected tool instructions in final prompt, got: %q", finalPrompt)
|
||||||
|
}
|
||||||
|
if guardIdx > toolIdx {
|
||||||
|
t.Fatalf("expected output integrity guard to precede tool instructions, got: %q", finalPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildOpenAIFinalPromptReadLikeToolIncludesCacheGuard(t *testing.T) {
|
func TestBuildOpenAIFinalPromptReadLikeToolIncludesCacheGuard(t *testing.T) {
|
||||||
messages := []any{
|
messages := []any{
|
||||||
map[string]any{"role": "user", "content": "请读取文件"},
|
map[string]any{"role": "user", "content": "请读取文件"},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"ds2api/internal/httpapi/openai/files"
|
"ds2api/internal/httpapi/openai/files"
|
||||||
"ds2api/internal/httpapi/openai/responses"
|
"ds2api/internal/httpapi/openai/responses"
|
||||||
"ds2api/internal/httpapi/openai/shared"
|
"ds2api/internal/httpapi/openai/shared"
|
||||||
|
"ds2api/internal/httpapi/requestbody"
|
||||||
"ds2api/internal/webui"
|
"ds2api/internal/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ func NewApp() (*App, error) {
|
|||||||
r.Use(filteredLogger())
|
r.Use(filteredLogger())
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(cors)
|
r.Use(cors)
|
||||||
|
r.Use(requestbody.ValidateJSONUTF8)
|
||||||
r.Use(timeout(0))
|
r.Use(timeout(0))
|
||||||
|
|
||||||
healthzHandler := func(w http.ResponseWriter, _ *http.Request) {
|
healthzHandler := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
89
internal/server/router_utf8_test.go
Normal file
89
internal/server/router_utf8_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSONRequestsRejectInvalidUTF8BeforeDecode(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["managed-key"],"accounts":[{"email":"u@example.com","password":"p"}]}`)
|
||||||
|
t.Setenv("DS2API_ENV_WRITEBACK", "0")
|
||||||
|
|
||||||
|
app, err := NewApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewApp() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := append([]byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"`), 0xff)
|
||||||
|
body = append(body, []byte(`"}]}`)...)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
req.Header.Set("x-api-key", "direct-token")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for invalid utf-8 request body, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid json") {
|
||||||
|
t.Fatalf("expected invalid json error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKnownJSONRequestsRejectInvalidUTF8WithoutJSONContentType(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["managed-key"],"accounts":[{"email":"u@example.com","password":"p"}]}`)
|
||||||
|
t.Setenv("DS2API_ENV_WRITEBACK", "0")
|
||||||
|
|
||||||
|
app, err := NewApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewApp() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := append([]byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"`), 0xff)
|
||||||
|
body = append(body, []byte(`"}]}`)...)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
req.Header.Set("x-api-key", "direct-token")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for invalid utf-8 request body, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid json") {
|
||||||
|
t.Fatalf("expected invalid json error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONRequestsRejectTrailingInvalidUTF8AfterCompleteJSON(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["managed-key"],"accounts":[{"email":"u@example.com","password":"p"}]}`)
|
||||||
|
t.Setenv("DS2API_ENV_WRITEBACK", "0")
|
||||||
|
|
||||||
|
app, err := NewApp()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewApp() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := append([]byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"ok"}]}`), 0xff)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", "direct-token")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
app.Router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for trailing invalid utf-8, got %d body=%q", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(rec.Body.String()), "invalid json") {
|
||||||
|
t.Fatalf("expected invalid json error, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
164
internal/toolcall/toolcalls_array_parse.go
Normal file
164
internal/toolcall/toolcalls_array_parse.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package toolcall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseLooseJSONArrayValue(raw, paramName string) ([]any, bool) {
|
||||||
|
if preservesCDATAStringParameter(paramName) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(html.UnescapeString(raw))
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed, ok := parseLooseJSONArrayCandidate(trimmed, paramName); ok {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
segments, ok := splitTopLevelJSONValues(trimmed)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]any, 0, len(segments))
|
||||||
|
for _, segment := range segments {
|
||||||
|
parsed, ok := parseLooseArrayElementValue(segment)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out = append(out, parsed)
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLooseJSONArrayCandidate(raw, paramName string) ([]any, bool) {
|
||||||
|
parsed, ok := parseLooseArrayElementValue(raw)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return coerceArrayValue(parsed, paramName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLooseArrayElementValue(raw string) (any, bool) {
|
||||||
|
trimmed := strings.TrimSpace(html.UnescapeString(raw))
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
repairedBackslashes := repairInvalidJSONBackslashes(trimmed)
|
||||||
|
if repairedBackslashes != trimmed {
|
||||||
|
if err := json.Unmarshal([]byte(repairedBackslashes), &parsed); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repairedLoose := RepairLooseJSON(trimmed)
|
||||||
|
if repairedLoose != trimmed {
|
||||||
|
if err := json.Unmarshal([]byte(repairedLoose), &parsed); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmed, "<") && strings.Contains(trimmed, ">") {
|
||||||
|
if parsedXML, ok := parseXMLFragmentValue(trimmed); ok {
|
||||||
|
return parsedXML, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceArrayValue(value any, paramName string) ([]any, bool) {
|
||||||
|
switch x := value.(type) {
|
||||||
|
case []any:
|
||||||
|
return x, true
|
||||||
|
case map[string]any:
|
||||||
|
if len(x) != 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if items, ok := x["item"]; ok {
|
||||||
|
if arr, ok := coerceArrayValue(items, ""); ok {
|
||||||
|
return arr, true
|
||||||
|
}
|
||||||
|
return []any{items}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if paramName != "" {
|
||||||
|
if wrapped, ok := x[paramName]; ok {
|
||||||
|
if arr, ok := coerceArrayValue(wrapped, ""); ok {
|
||||||
|
return arr, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitTopLevelJSONValues(raw string) ([]string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, 2)
|
||||||
|
start := 0
|
||||||
|
depth := 0
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for i, r := range trimmed {
|
||||||
|
if inString {
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case '\\':
|
||||||
|
escaped = true
|
||||||
|
case '"':
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r {
|
||||||
|
case '"':
|
||||||
|
inString = true
|
||||||
|
case '{', '[':
|
||||||
|
depth++
|
||||||
|
case '}', ']':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
case ',':
|
||||||
|
if depth == 0 {
|
||||||
|
segment := strings.TrimSpace(trimmed[start:i])
|
||||||
|
if segment == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
values = append(values, segment)
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last := strings.TrimSpace(trimmed[start:])
|
||||||
|
if last == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
values = append(values, last)
|
||||||
|
if len(values) < 2 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return values, true
|
||||||
|
}
|
||||||
@@ -298,11 +298,17 @@ func parseInvokeParameterValue(paramName, raw string) any {
|
|||||||
}
|
}
|
||||||
if value, ok := extractStandaloneCDATA(trimmed); ok {
|
if value, ok := extractStandaloneCDATA(trimmed); ok {
|
||||||
if parsed, ok := parseJSONLiteralValue(value); ok {
|
if parsed, ok := parseJSONLiteralValue(value); ok {
|
||||||
|
if parsedArray, ok := coerceArrayValue(parsed, paramName); ok {
|
||||||
|
return parsedArray
|
||||||
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
if parsed, ok := parseStructuredCDATAParameterValue(paramName, value); ok {
|
if parsed, ok := parseStructuredCDATAParameterValue(paramName, value); ok {
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
if parsed, ok := parseLooseJSONArrayValue(value, paramName); ok {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
decoded := html.UnescapeString(extractRawTagValue(trimmed))
|
decoded := html.UnescapeString(extractRawTagValue(trimmed))
|
||||||
@@ -311,6 +317,9 @@ func parseInvokeParameterValue(paramName, raw string) any {
|
|||||||
switch v := parsedValue.(type) {
|
switch v := parsedValue.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
if len(v) > 0 {
|
if len(v) > 0 {
|
||||||
|
if parsedArray, ok := coerceArrayValue(v, paramName); ok {
|
||||||
|
return parsedArray
|
||||||
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
case []any:
|
case []any:
|
||||||
@@ -321,6 +330,12 @@ func parseInvokeParameterValue(paramName, raw string) any {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if parsedText, ok := parseJSONLiteralValue(text); ok {
|
if parsedText, ok := parseJSONLiteralValue(text); ok {
|
||||||
|
if parsedArray, ok := coerceArrayValue(parsedText, paramName); ok {
|
||||||
|
return parsedArray
|
||||||
|
}
|
||||||
|
return parsedText
|
||||||
|
}
|
||||||
|
if parsedText, ok := parseLooseJSONArrayValue(text, paramName); ok {
|
||||||
return parsedText
|
return parsedText
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
@@ -331,13 +346,25 @@ func parseInvokeParameterValue(paramName, raw string) any {
|
|||||||
if parsed := parseStructuredToolCallInput(decoded); len(parsed) > 0 {
|
if parsed := parseStructuredToolCallInput(decoded); len(parsed) > 0 {
|
||||||
if len(parsed) == 1 {
|
if len(parsed) == 1 {
|
||||||
if rawValue, ok := parsed["_raw"].(string); ok {
|
if rawValue, ok := parsed["_raw"].(string); ok {
|
||||||
|
if parsedText, ok := parseLooseJSONArrayValue(rawValue, paramName); ok {
|
||||||
|
return parsedText
|
||||||
|
}
|
||||||
return rawValue
|
return rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if parsedArray, ok := coerceArrayValue(parsed, paramName); ok {
|
||||||
|
return parsedArray
|
||||||
|
}
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if parsed, ok := parseJSONLiteralValue(decoded); ok {
|
if parsed, ok := parseJSONLiteralValue(decoded); ok {
|
||||||
|
if parsedArray, ok := coerceArrayValue(parsed, paramName); ok {
|
||||||
|
return parsedArray
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
if parsed, ok := parseLooseJSONArrayValue(decoded, paramName); ok {
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
|
|||||||
@@ -294,6 +294,59 @@ func TestParseToolCallsTreatsSingleItemCDATAAsArray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseToolCallsTreatsLooseJSONListAsArray(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
body: `{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cdata",
|
||||||
|
body: `<![CDATA[{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}]]>`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
text := `<tool_calls><invoke name="TodoWrite"><parameter name="todos">` + tt.body + `</parameter></invoke></tool_calls>`
|
||||||
|
calls := ParseToolCalls(text, []string{"TodoWrite"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected one TodoWrite call, got %#v", calls)
|
||||||
|
}
|
||||||
|
items, ok := calls[0].Input["todos"].([]any)
|
||||||
|
if !ok || len(items) != 2 {
|
||||||
|
t.Fatalf("expected loose JSON list to parse as array, got %#v", calls[0].Input["todos"])
|
||||||
|
}
|
||||||
|
first, ok := items[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected first todo object, got %#v", items[0])
|
||||||
|
}
|
||||||
|
if first["content"] != "Test TodoWrite tool" || first["status"] != "completed" {
|
||||||
|
t.Fatalf("unexpected first todo: %#v", first)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseToolCallsKeepsPreservedTextParametersAsText(t *testing.T) {
|
||||||
|
text := `<tool_calls><invoke name="Write"><parameter name="content"><![CDATA[{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}]]></parameter></invoke></tool_calls>`
|
||||||
|
calls := ParseToolCalls(text, []string{"Write"})
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected one Write call, got %#v", calls)
|
||||||
|
}
|
||||||
|
got, ok := calls[0].Input["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected content to stay a string, got %#v", calls[0].Input["content"])
|
||||||
|
}
|
||||||
|
want := `{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected content to stay raw, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseToolCallsTreatsCDATAObjectFragmentAsObject(t *testing.T) {
|
func TestParseToolCallsTreatsCDATAObjectFragmentAsObject(t *testing.T) {
|
||||||
payload := `<question><![CDATA[Pick one]]></question><options><item><label><![CDATA[A]]></label></item><item><label><![CDATA[B]]></label></item></options>`
|
payload := `<question><![CDATA[Pick one]]></question><options><item><label><![CDATA[A]]></label></item><item><label><![CDATA[B]]></label></item></options>`
|
||||||
text := `<tool_calls><invoke name="AskUserQuestion"><parameter name="questions"><![CDATA[` + payload + `]]></parameter></invoke></tool_calls>`
|
text := `<tool_calls><invoke name="AskUserQuestion"><parameter name="questions"><![CDATA[` + payload + `]]></parameter></invoke></tool_calls>`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"ds2api/internal/config"
|
"ds2api/internal/config"
|
||||||
@@ -12,7 +13,10 @@ func TestMessagesPrepareBasic(t *testing.T) {
|
|||||||
if got == "" {
|
if got == "" {
|
||||||
t.Fatal("expected non-empty prompt")
|
t.Fatal("expected non-empty prompt")
|
||||||
}
|
}
|
||||||
if got != "<|begin▁of▁sentence|><|User|>Hello<|Assistant|>" {
|
if !strings.HasPrefix(got, "<|begin▁of▁sentence|><|System|>") {
|
||||||
|
t.Fatalf("expected output integrity guard at the start, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "Hello") || !strings.HasSuffix(got, "<|Assistant|>") {
|
||||||
t.Fatalf("unexpected prompt: %q", got)
|
t.Fatalf("unexpected prompt: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,8 +30,11 @@ func TestMessagesPrepareRoles(t *testing.T) {
|
|||||||
{"role": "user", "content": "How are you"},
|
{"role": "user", "content": "How are you"},
|
||||||
}
|
}
|
||||||
got := MessagesPrepare(messages)
|
got := MessagesPrepare(messages)
|
||||||
if !contains(got, "<|System|>You are helper<|end▁of▁instructions|><|User|>Hi") {
|
if !contains(got, "Output integrity guard") {
|
||||||
t.Fatalf("expected system/user separation in %q", got)
|
t.Fatalf("expected output integrity guard in %q", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "You are helper") || !contains(got, "<|User|>Hi") {
|
||||||
|
t.Fatalf("expected system/user content in %q", got)
|
||||||
}
|
}
|
||||||
if !contains(got, "<|begin▁of▁sentence|>") {
|
if !contains(got, "<|begin▁of▁sentence|>") {
|
||||||
t.Fatalf("expected begin marker in %q", got)
|
t.Fatalf("expected begin marker in %q", got)
|
||||||
@@ -77,9 +84,12 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := MessagesPrepare(messages)
|
got := MessagesPrepare(messages)
|
||||||
if got != "<|begin▁of▁sentence|><|User|>line1\nline2<|Assistant|>" {
|
if !contains(got, "line1\nline2") {
|
||||||
t.Fatalf("unexpected content from text variants: %q", got)
|
t.Fatalf("unexpected content from text variants: %q", got)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "Output integrity guard") {
|
||||||
|
t.Fatalf("expected output integrity guard in %q", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConvertClaudeToDeepSeek(t *testing.T) {
|
func TestConvertClaudeToDeepSeek(t *testing.T) {
|
||||||
|
|||||||
@@ -4,19 +4,26 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
tiktoken "github.com/hupe1980/go-tiktoken"
|
tiktoken "github.com/hupe1980/go-tiktoken"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tokenEncodingPools sync.Map
|
||||||
|
tokenEncodingUnsupported sync.Map
|
||||||
|
)
|
||||||
|
|
||||||
func countWithTokenizer(text, model string) int {
|
func countWithTokenizer(text, model string) int {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
encoding, err := tiktoken.NewEncodingForModel(tokenizerModelForCount(model))
|
encoding, release := tokenizerEncodingForCount(tokenizerModelForCount(model))
|
||||||
if err != nil {
|
if encoding == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
defer release()
|
||||||
ids, _, err := encoding.Encode(text, nil, nil)
|
ids, _, err := encoding.Encode(text, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -24,6 +31,53 @@ func countWithTokenizer(text, model string) int {
|
|||||||
return len(ids)
|
return len(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tokenizerEncodingForCount(model string) (*tiktoken.Encoding, func()) {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
model = defaultTokenizerModel
|
||||||
|
}
|
||||||
|
if _, ok := tokenEncodingUnsupported.Load(model); ok {
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
if rawPool, ok := tokenEncodingPools.Load(model); ok {
|
||||||
|
pool, _ := rawPool.(*sync.Pool)
|
||||||
|
return getEncodingFromPool(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding, err := tiktoken.NewEncodingForModel(model)
|
||||||
|
if err != nil {
|
||||||
|
tokenEncodingUnsupported.Store(model, struct{}{})
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
pool := &sync.Pool{
|
||||||
|
New: func() any {
|
||||||
|
encoding, err := tiktoken.NewEncodingForModel(model)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return encoding
|
||||||
|
},
|
||||||
|
}
|
||||||
|
actualPool, _ := tokenEncodingPools.LoadOrStore(model, pool)
|
||||||
|
pool, _ = actualPool.(*sync.Pool)
|
||||||
|
return encoding, func() {
|
||||||
|
pool.Put(encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEncodingFromPool(pool *sync.Pool) (*tiktoken.Encoding, func()) {
|
||||||
|
if pool == nil {
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
encoding, _ := pool.Get().(*tiktoken.Encoding)
|
||||||
|
if encoding == nil {
|
||||||
|
return nil, func() {}
|
||||||
|
}
|
||||||
|
return encoding, func() {
|
||||||
|
pool.Put(encoding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tokenizerModelForCount(model string) string {
|
func tokenizerModelForCount(model string) string {
|
||||||
model = strings.ToLower(strings.TrimSpace(model))
|
model = strings.ToLower(strings.TrimSpace(model))
|
||||||
if model == "" {
|
if model == "" {
|
||||||
|
|||||||
35
internal/util/token_count_tiktoken_test.go
Normal file
35
internal/util/token_count_tiktoken_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//go:build !386 && !arm && !mips && !mipsle && !wasm
|
||||||
|
|
||||||
|
package util
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTokenizerEncodingForCountCachesSupportedModel(t *testing.T) {
|
||||||
|
encoding, release := tokenizerEncodingForCount(defaultTokenizerModel)
|
||||||
|
if encoding == nil {
|
||||||
|
t.Fatalf("expected tokenizer encoding for %q", defaultTokenizerModel)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
|
||||||
|
if _, ok := tokenEncodingPools.Load(defaultTokenizerModel); !ok {
|
||||||
|
t.Fatalf("expected tokenizer encoding pool for %q", defaultTokenizerModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoding, release = tokenizerEncodingForCount(defaultTokenizerModel)
|
||||||
|
if encoding == nil {
|
||||||
|
t.Fatalf("expected cached tokenizer encoding for %q", defaultTokenizerModel)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenizerEncodingForCountCachesUnsupportedModel(t *testing.T) {
|
||||||
|
const model = "__ds2api_unsupported_tokenizer_model__"
|
||||||
|
encoding, release := tokenizerEncodingForCount(model)
|
||||||
|
release()
|
||||||
|
if encoding != nil {
|
||||||
|
t.Fatalf("expected nil encoding for unsupported model %q", model)
|
||||||
|
}
|
||||||
|
if _, ok := tokenEncodingUnsupported.Load(model); !ok {
|
||||||
|
t.Fatalf("expected unsupported tokenizer model to be cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Node split syntax gate targets
|
|
||||||
# Keep this list in sync with api/chat-stream and internal/js/helpers/stream-tool-sieve split modules.
|
|
||||||
|
|
||||||
api/chat-stream.js
|
|
||||||
internal/js/chat-stream/index.js
|
|
||||||
internal/js/chat-stream/error_shape.js
|
|
||||||
internal/js/chat-stream/http_internal.js
|
|
||||||
internal/js/chat-stream/proxy_go.js
|
|
||||||
internal/js/chat-stream/sse_parse.js
|
|
||||||
internal/js/chat-stream/stream_emitter.js
|
|
||||||
internal/js/chat-stream/token_usage.js
|
|
||||||
internal/js/chat-stream/toolcall_policy.js
|
|
||||||
internal/js/chat-stream/vercel_stream.js
|
|
||||||
|
|
||||||
internal/js/helpers/stream-tool-sieve.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/index.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/state.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/sieve.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/sieve-xml.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/jsonscan.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/parse.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/format.js
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Line gate targets for large-file decoupling refactor.
|
|
||||||
# Backend default limit: 300 lines
|
|
||||||
# Frontend (webui/) default limit: 500 lines
|
|
||||||
# Entry/facade limit: 120 lines (enforced in script)
|
|
||||||
# Test files are ignored by the gate script.
|
|
||||||
|
|
||||||
internal/config/config.go
|
|
||||||
internal/config/logger.go
|
|
||||||
internal/config/paths.go
|
|
||||||
internal/config/codec.go
|
|
||||||
internal/config/store.go
|
|
||||||
internal/config/store_index.go
|
|
||||||
internal/config/store_accessors.go
|
|
||||||
internal/config/account.go
|
|
||||||
|
|
||||||
internal/httpapi/admin/configmgmt/handler_config_read.go
|
|
||||||
internal/httpapi/admin/configmgmt/handler_config_write.go
|
|
||||||
internal/httpapi/admin/configmgmt/handler_config_import.go
|
|
||||||
internal/httpapi/admin/settings/handler_settings_read.go
|
|
||||||
internal/httpapi/admin/settings/handler_settings_write.go
|
|
||||||
internal/httpapi/admin/settings/handler_settings_parse.go
|
|
||||||
internal/httpapi/admin/settings/handler_settings_runtime.go
|
|
||||||
internal/httpapi/admin/accounts/handler_accounts_crud.go
|
|
||||||
internal/httpapi/admin/accounts/handler_accounts_testing.go
|
|
||||||
internal/httpapi/admin/accounts/handler_accounts_queue.go
|
|
||||||
|
|
||||||
internal/account/pool_core.go
|
|
||||||
internal/account/pool_acquire.go
|
|
||||||
internal/account/pool_waiters.go
|
|
||||||
internal/account/pool_limits.go
|
|
||||||
|
|
||||||
internal/deepseek/client/client_core.go
|
|
||||||
internal/deepseek/client/client_auth.go
|
|
||||||
internal/deepseek/client/client_completion.go
|
|
||||||
internal/deepseek/client/client_http_json.go
|
|
||||||
internal/deepseek/client/client_http_helpers.go
|
|
||||||
|
|
||||||
internal/format/openai/render_chat.go
|
|
||||||
internal/format/openai/render_responses.go
|
|
||||||
internal/format/openai/render_stream_events.go
|
|
||||||
internal/format/openai/render_usage.go
|
|
||||||
|
|
||||||
internal/httpapi/openai/shared/models.go
|
|
||||||
internal/httpapi/openai/chat/handler_chat.go
|
|
||||||
internal/httpapi/openai/shared/handler_errors.go
|
|
||||||
internal/httpapi/openai/shared/handler_toolcall_policy.go
|
|
||||||
internal/httpapi/openai/shared/handler_toolcall_format.go
|
|
||||||
internal/httpapi/openai/responses/responses_handler.go
|
|
||||||
internal/promptcompat/responses_input_normalize.go
|
|
||||||
internal/promptcompat/responses_input_items.go
|
|
||||||
internal/httpapi/openai/responses/responses_stream_runtime_core.go
|
|
||||||
internal/httpapi/openai/responses/responses_stream_runtime_events.go
|
|
||||||
internal/httpapi/openai/responses/responses_stream_runtime_toolcalls.go
|
|
||||||
internal/toolstream/tool_sieve_state.go
|
|
||||||
internal/toolstream/tool_sieve_core.go
|
|
||||||
internal/toolstream/tool_sieve_xml.go
|
|
||||||
internal/toolstream/tool_sieve_jsonscan.go
|
|
||||||
|
|
||||||
internal/toolcall/toolcalls_parse.go
|
|
||||||
internal/toolcall/toolcalls_candidates.go
|
|
||||||
internal/toolcall/toolcalls_format.go
|
|
||||||
|
|
||||||
internal/httpapi/claude/handler_routes.go
|
|
||||||
internal/httpapi/claude/handler_messages.go
|
|
||||||
internal/httpapi/claude/handler_tokens.go
|
|
||||||
internal/httpapi/claude/handler_errors.go
|
|
||||||
internal/httpapi/claude/handler_utils.go
|
|
||||||
internal/httpapi/claude/stream_runtime_core.go
|
|
||||||
internal/httpapi/claude/stream_runtime_emit.go
|
|
||||||
internal/httpapi/claude/stream_runtime_finalize.go
|
|
||||||
|
|
||||||
internal/httpapi/gemini/handler_routes.go
|
|
||||||
internal/httpapi/gemini/handler_generate.go
|
|
||||||
internal/httpapi/gemini/handler_stream_runtime.go
|
|
||||||
internal/httpapi/gemini/handler_errors.go
|
|
||||||
internal/httpapi/gemini/convert_request.go
|
|
||||||
internal/httpapi/gemini/convert_messages.go
|
|
||||||
internal/httpapi/gemini/convert_tools.go
|
|
||||||
internal/httpapi/gemini/convert_passthrough.go
|
|
||||||
|
|
||||||
internal/testsuite/runner_core.go
|
|
||||||
internal/testsuite/runner_env.go
|
|
||||||
internal/testsuite/runner_http.go
|
|
||||||
internal/testsuite/runner_cases_openai.go
|
|
||||||
internal/testsuite/runner_cases_openai_advanced.go
|
|
||||||
internal/testsuite/runner_cases_admin.go
|
|
||||||
internal/testsuite/runner_cases_claude.go
|
|
||||||
internal/testsuite/runner_summary.go
|
|
||||||
internal/testsuite/runner_utils.go
|
|
||||||
internal/testsuite/runner_defaults.go
|
|
||||||
internal/testsuite/runner_registry.go
|
|
||||||
internal/testsuite/edge_cases_abort.go
|
|
||||||
internal/testsuite/edge_cases_error_contract.go
|
|
||||||
|
|
||||||
api/chat-stream.js
|
|
||||||
internal/js/chat-stream/index.js
|
|
||||||
internal/js/chat-stream/vercel_stream.js
|
|
||||||
internal/js/chat-stream/proxy_go.js
|
|
||||||
internal/js/chat-stream/sse_parse.js
|
|
||||||
internal/js/chat-stream/http_internal.js
|
|
||||||
internal/js/chat-stream/toolcall_policy.js
|
|
||||||
internal/js/chat-stream/error_shape.js
|
|
||||||
internal/js/chat-stream/token_usage.js
|
|
||||||
internal/js/chat-stream/stream_emitter.js
|
|
||||||
|
|
||||||
internal/js/helpers/stream-tool-sieve.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/index.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/state.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/sieve.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/sieve-xml.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/jsonscan.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/parse.js
|
|
||||||
internal/js/helpers/stream-tool-sieve/format.js
|
|
||||||
|
|
||||||
webui/src/App.jsx
|
|
||||||
webui/src/app/AppRoutes.jsx
|
|
||||||
webui/src/app/useAdminAuth.js
|
|
||||||
webui/src/app/useAdminConfig.js
|
|
||||||
webui/src/layout/DashboardShell.jsx
|
|
||||||
|
|
||||||
webui/src/features/account/AccountManagerContainer.jsx
|
|
||||||
webui/src/features/account/useAccountsData.js
|
|
||||||
webui/src/features/account/useAccountActions.js
|
|
||||||
webui/src/features/account/QueueCards.jsx
|
|
||||||
webui/src/features/account/ApiKeysPanel.jsx
|
|
||||||
webui/src/features/account/AccountsTable.jsx
|
|
||||||
webui/src/features/account/AddKeyModal.jsx
|
|
||||||
webui/src/features/account/AddAccountModal.jsx
|
|
||||||
|
|
||||||
webui/src/features/apiTester/ApiTesterContainer.jsx
|
|
||||||
webui/src/features/apiTester/useApiTesterState.js
|
|
||||||
webui/src/features/apiTester/useChatStreamClient.js
|
|
||||||
webui/src/features/apiTester/ConfigPanel.jsx
|
|
||||||
webui/src/features/apiTester/ChatPanel.jsx
|
|
||||||
|
|
||||||
webui/src/features/settings/SettingsContainer.jsx
|
|
||||||
webui/src/features/settings/useSettingsForm.js
|
|
||||||
webui/src/features/settings/settingsApi.js
|
|
||||||
webui/src/features/settings/SecuritySection.jsx
|
|
||||||
webui/src/features/settings/RuntimeSection.jsx
|
|
||||||
webui/src/features/settings/BehaviorSection.jsx
|
|
||||||
webui/src/features/settings/ModelSection.jsx
|
|
||||||
webui/src/features/settings/BackupSection.jsx
|
|
||||||
|
|
||||||
webui/src/features/vercel/VercelSyncContainer.jsx
|
|
||||||
webui/src/features/vercel/useVercelSyncState.js
|
|
||||||
webui/src/features/vercel/VercelSyncForm.jsx
|
|
||||||
webui/src/features/vercel/VercelSyncStatus.jsx
|
|
||||||
webui/src/features/vercel/VercelGuide.jsx
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Stage 6 Manual Smoke Checklist
|
|
||||||
|
|
||||||
- Date: 2026-02-22
|
|
||||||
- Tester: release-maintainer
|
|
||||||
- Environment: local macOS + latest Chrome
|
|
||||||
|
|
||||||
## Items
|
|
||||||
|
|
||||||
1. Login flow (`/admin/login`) succeeds and failure message shape unchanged.
|
|
||||||
2. Account manager:
|
|
||||||
- add/edit/delete account
|
|
||||||
- queue status cards render and refresh
|
|
||||||
3. API tester:
|
|
||||||
- non-stream request succeeds
|
|
||||||
- stream request receives incremental output and final state
|
|
||||||
4. Settings:
|
|
||||||
- read settings
|
|
||||||
- save settings
|
|
||||||
- backup/export path works
|
|
||||||
5. Vercel sync:
|
|
||||||
- status poll
|
|
||||||
- manual refresh
|
|
||||||
- sync action and status feedback text
|
|
||||||
|
|
||||||
## Result
|
|
||||||
|
|
||||||
- Status: `PASS`
|
|
||||||
- Notes: login/account/api-tester/settings/vercel-sync smoke passed with no behavior regressions.
|
|
||||||
@@ -231,6 +231,28 @@ test('parseToolCalls treats single-item CDATA body as array', () => {
|
|||||||
assert.deepEqual(calls[0].input.todos, ['one']);
|
assert.deepEqual(calls[0].input.todos, ['one']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseToolCalls treats loose JSON list as array', () => {
|
||||||
|
for (const [label, body] of [
|
||||||
|
['plain text', '{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}'],
|
||||||
|
['cdata', '<![CDATA[{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}]]>'],
|
||||||
|
]) {
|
||||||
|
const payload = `<tool_calls><invoke name="TodoWrite"><parameter name="todos">${body}</parameter></invoke></tool_calls>`;
|
||||||
|
const calls = parseToolCalls(payload, ['TodoWrite']);
|
||||||
|
assert.equal(calls.length, 1, label);
|
||||||
|
assert.deepEqual(calls[0].input.todos, [
|
||||||
|
{ content: 'Test TodoWrite tool', status: 'completed' },
|
||||||
|
{ content: 'Another task', status: 'pending' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseToolCalls keeps preserved text parameters as text', () => {
|
||||||
|
const payload = '<tool_calls><invoke name="Write"><parameter name="content"><![CDATA[{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}]]></parameter></invoke></tool_calls>';
|
||||||
|
const calls = parseToolCalls(payload, ['Write']);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].input.content, '{"content":"Test TodoWrite tool","status":"completed"}, {"content":"Another task","status":"pending"}');
|
||||||
|
});
|
||||||
|
|
||||||
test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => {
|
test('formatOpenAIStreamToolCalls normalizes camelCase inputSchema string fields', () => {
|
||||||
const formatted = formatOpenAIStreamToolCalls([
|
const formatted = formatOpenAIStreamToolCalls([
|
||||||
{ name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } },
|
{ name: 'Write', input: { content: { message: 'hi' }, taskId: 1 } },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-b10a2aca-39e9-4a38-be9d-9f22e398cb62","status":"PENDING","file_name":"history.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":null,"error_code":null,"inserted_at":1777485015.255,"updated_at":1777485015.255,"is_image":false,"audit_result":null}}}
|
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-b10a2aca-39e9-4a38-be9d-9f22e398cb62","status":"PENDING","file_name":"DS2API_HISTORY.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":null,"error_code":null,"inserted_at":1777485015.255,"updated_at":1777485015.255,"is_image":false,"audit_result":null}}}
|
||||||
event: ready
|
event: ready
|
||||||
data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}
|
data: {"request_message_id":1,"response_message_id":2,"model_type":"default"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-9c8ae986-75f7-4611-9956-5e1b502f3ec2","status":"SUCCESS","file_name":"history.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":145,"error_code":null,"inserted_at":1777485076.42,"updated_at":1777485076.42,"signed_path":"/file?file_id=9c8ae986-75f7-4611-9956-5e1b502f3ec2&state=a1REa2AdO8JmDuxMFiUTPJfpiyY4ie2weyUpYxfvEOrk5lxUCZifpRw9toZAEzn3DAjkgbR6blgZf41KLkHBKwwrcYTIjfxTRKijDqjEfguis03yddpuVrii6keG4%2BXIlcLAsyZG3qcGhfTGVZhsr%2BRl17J%2BcnT9roslhxBcEy4rthFJVMWUI%2BSHjuo2gLEUDfvMfULQ1gSLVGtr%2Fpq%2FcNPCPSxZapIQv04ZVmJLcdbzRkz%2Bb%2BxM5RWUIPujp%2B3ke1WDa3%2B6S4pP0Pv%2BAJ0MFUjQsloUwO4AsJ8YhGBFWg8Ehe1b2yt1N%2Fi%2BIjLRPt5xiNmALcJJXIY%3D","is_image":false,"audit_result":null}}}
|
{"code":0,"msg":"","data":{"biz_code":0,"biz_msg":"","biz_data":{"id":"file-9c8ae986-75f7-4611-9956-5e1b502f3ec2","status":"SUCCESS","file_name":"DS2API_HISTORY.txt","from_share":false,"file_size":732,"model_kind":"NORMAL","token_usage":145,"error_code":null,"inserted_at":1777485076.42,"updated_at":1777485076.42,"signed_path":"/file?file_id=9c8ae986-75f7-4611-9956-5e1b502f3ec2&state=a1REa2AdO8JmDuxMFiUTPJfpiyY4ie2weyUpYxfvEOrk5lxUCZifpRw9toZAEzn3DAjkgbR6blgZf41KLkHBKwwrcYTIjfxTRKijDqjEfguis03yddpuVrii6keG4%2BXIlcLAsyZG3qcGhfTGVZhsr%2BRl17J%2BcnT9roslhxBcEy4rthFJVMWUI%2BSHjuo2gLEUDfvMfULQ1gSLVGtr%2Fpq%2FcNPCPSxZapIQv04ZVmJLcdbzRkz%2Bb%2BxM5RWUIPujp%2B3ke1WDa3%2B6S4pP0Pv%2BAJ0MFUjQsloUwO4AsJ8YhGBFWg8Ehe1b2yt1N%2Fi%2BIjLRPt5xiNmALcJJXIY%3D","is_image":false,"audit_result":null}}}
|
||||||
event: ready
|
event: ready
|
||||||
data: {"request_message_id":1,"response_message_id":2,"model_type":"expert"}
|
data: {"request_message_id":1,"response_message_id":2,"model_type":"expert"}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|||||||
TARGETS_FILE="${1:-$ROOT_DIR/plans/node-syntax-gate-targets.txt}"
|
TARGETS_FILE="${1:-$ROOT_DIR/plans/node-syntax-gate-targets.txt}"
|
||||||
|
|
||||||
if [[ ! -f "$TARGETS_FILE" ]]; then
|
if [[ ! -f "$TARGETS_FILE" ]]; then
|
||||||
echo "missing targets file: $TARGETS_FILE" >&2
|
echo "checked=0 missing=0 invalid=0"
|
||||||
exit 1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
checked=0
|
checked=0
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ is_test_file() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ ! -f "$TARGETS_FILE" ]]; then
|
if [[ ! -f "$TARGETS_FILE" ]]; then
|
||||||
echo "missing targets file: $TARGETS_FILE" >&2
|
echo "checked=0 missing=0 over_limit=0"
|
||||||
exit 1
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
missing=0
|
missing=0
|
||||||
|
|||||||
16
vercel.json
16
vercel.json
@@ -81,6 +81,22 @@
|
|||||||
"source": "/admin/version",
|
"source": "/admin/version",
|
||||||
"destination": "/api/index"
|
"destination": "/api/index"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/admin/chat-history(.*)",
|
||||||
|
"destination": "/api/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/admin/proxies(.*)",
|
||||||
|
"destination": "/api/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/admin/dev/raw-samples/(.*)",
|
||||||
|
"destination": "/api/index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/admin/dev/captures(.*)",
|
||||||
|
"destination": "/api/index"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/admin",
|
"source": "/admin",
|
||||||
"destination": "/admin/index.html"
|
"destination": "/admin/index.html"
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) {
|
|||||||
setSelectedAccount={setSelectedAccount}
|
setSelectedAccount={setSelectedAccount}
|
||||||
effectiveKey={effectiveKey}
|
effectiveKey={effectiveKey}
|
||||||
selectedAccount={selectedAccount}
|
selectedAccount={selectedAccount}
|
||||||
|
model={model}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
response={response}
|
response={response}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function ChatPanel({
|
|||||||
setSelectedAccount,
|
setSelectedAccount,
|
||||||
effectiveKey,
|
effectiveKey,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
|
model,
|
||||||
onMessage,
|
onMessage,
|
||||||
response,
|
response,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -37,11 +38,15 @@ export default function ChatPanel({
|
|||||||
|
|
||||||
setUploadingFiles(true)
|
setUploadingFiles(true)
|
||||||
const initialSelectedAccount = String(selectedAccount || '').trim()
|
const initialSelectedAccount = String(selectedAccount || '').trim()
|
||||||
|
const selectedModel = String(model || '').trim()
|
||||||
let boundAccount = initialSelectedAccount
|
let boundAccount = initialSelectedAccount
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
formData.append('purpose', 'assistants')
|
formData.append('purpose', 'assistants')
|
||||||
|
if (selectedModel) {
|
||||||
|
formData.append('model', selectedModel)
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Authorization': `Bearer ${effectiveKey}`,
|
'Authorization': `Bearer ${effectiveKey}`,
|
||||||
@@ -181,8 +186,9 @@ export default function ChatPanel({
|
|||||||
/>
|
/>
|
||||||
<div className="absolute left-2 bottom-2 z-10">
|
<div className="absolute left-2 bottom-2 z-10">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingFiles || isStreaming || !hasAvailableModel}
|
disabled={uploadingFiles || isStreaming}
|
||||||
className="p-2 text-muted-foreground hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2 text-muted-foreground hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
title="Attach files"
|
title="Attach files"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ const TOOL_MARKER = '<|Tool|>'
|
|||||||
const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>'
|
const END_INSTRUCTIONS_MARKER = '<|end▁of▁instructions|>'
|
||||||
const END_SENTENCE_MARKER = '<|end▁of▁sentence|>'
|
const END_SENTENCE_MARKER = '<|end▁of▁sentence|>'
|
||||||
const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>'
|
const END_TOOL_RESULTS_MARKER = '<|end▁of▁toolresults|>'
|
||||||
const CURRENT_INPUT_FILE_PROMPT = 'The current request and prior conversation context have already been provided. Answer the latest user request directly.'
|
const CURRENT_INPUT_FILE_PROMPT = 'Continue from the latest state in the attached DS2API_HISTORY.txt context. Treat it as the current working state and answer the latest user request directly.'
|
||||||
|
const LEGACY_CURRENT_INPUT_FILE_PROMPTS = new Set([
|
||||||
|
'The current request and prior conversation context have already been provided. Answer the latest user request directly.',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isCurrentInputFilePrompt(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
return text === CURRENT_INPUT_FILE_PROMPT || LEGACY_CURRENT_INPUT_FILE_PROMPTS.has(text)
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value, lang) {
|
function formatDateTime(value, lang) {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
@@ -312,7 +320,7 @@ function buildListModeMessages(item, t) {
|
|||||||
|
|
||||||
const placeholderOnly = liveMessages.length === 1
|
const placeholderOnly = liveMessages.length === 1
|
||||||
&& String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user'
|
&& String(liveMessages[0]?.role || '').trim().toLowerCase() === 'user'
|
||||||
&& String(liveMessages[0]?.content || '').trim() === CURRENT_INPUT_FILE_PROMPT
|
&& isCurrentInputFilePrompt(liveMessages[0]?.content)
|
||||||
|
|
||||||
if (placeholderOnly) {
|
if (placeholderOnly) {
|
||||||
return { messages: historyMessages, historyMerged: true }
|
return { messages: historyMessages, historyMerged: true }
|
||||||
|
|||||||
@@ -394,7 +394,7 @@
|
|||||||
"thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
|
"thinkingInjectionPromptHelp": "Leave empty to use the built-in default prompt shown as the input placeholder.",
|
||||||
"currentInputFileTitle": "Independent Split",
|
"currentInputFileTitle": "Independent Split",
|
||||||
"currentInputFileEnabled": "Independent split (by size)",
|
"currentInputFileEnabled": "Independent split (by size)",
|
||||||
"currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a history.txt context file.",
|
"currentInputFileDesc": "Enabled by default. Once the character threshold is reached, upload the full context as a DS2API_HISTORY.txt context file.",
|
||||||
"currentInputFileMinChars": "Current input threshold (characters)",
|
"currentInputFileMinChars": "Current input threshold (characters)",
|
||||||
"currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
|
"currentInputFileHelp": "Default is 0, which uses independent split for any non-empty input.",
|
||||||
"compatibilityTitle": "Compatibility",
|
"compatibilityTitle": "Compatibility",
|
||||||
|
|||||||
@@ -394,7 +394,7 @@
|
|||||||
"thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
|
"thinkingInjectionPromptHelp": "留空时使用内置默认提示词;默认内容会显示在输入框占位文本中。",
|
||||||
"currentInputFileTitle": "独立拆分",
|
"currentInputFileTitle": "独立拆分",
|
||||||
"currentInputFileEnabled": "独立拆分(按量)",
|
"currentInputFileEnabled": "独立拆分(按量)",
|
||||||
"currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为 history.txt 上下文文件。",
|
"currentInputFileDesc": "默认开启。达到字符阈值后,将完整上下文上传为 DS2API_HISTORY.txt 上下文文件。",
|
||||||
"currentInputFileMinChars": "当前输入阈值(字符数)",
|
"currentInputFileMinChars": "当前输入阈值(字符数)",
|
||||||
"currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
|
"currentInputFileHelp": "默认 0,表示只要有输入就会使用独立拆分。",
|
||||||
"compatibilityTitle": "兼容性设置",
|
"compatibilityTitle": "兼容性设置",
|
||||||
|
|||||||
Reference in New Issue
Block a user