diff --git a/API.en.md b/API.en.md index a0b2f787..d363d3e3 100644 --- a/API.en.md +++ b/API.en.md @@ -173,7 +173,7 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= ### `GET /v1/models` -No auth required. Returns supported models. +No auth required. Returns the currently supported DeepSeek native model list. **Response**: @@ -184,11 +184,21 @@ No auth required. Returns supported models. {"id": "deepseek-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, {"id": "deepseek-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, {"id": "deepseek-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, - {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} + {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} ] } ``` +> Note: `/v1/models` returns normalized DeepSeek native model IDs. Common aliases are accepted only as request input and are not expanded as separate items in this endpoint. + ### Model Alias Resolution For `chat` / `responses` / `embeddings`, DS2API follows a wide-input/strict-output policy: @@ -211,7 +221,7 @@ Content-Type: application/json | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-4o`, `gpt-5-codex`, `o3`, `claude-sonnet-4-5`, etc.) | +| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-4o`, `gpt-5-codex`, `o3`, `claude-sonnet-4-5`, `gemini-2.5-pro`, etc.) | | `messages` | array | ✅ | OpenAI-style messages | | `stream` | boolean | ❌ | Default `false` | | `tools` | array | ❌ | Function calling schema | @@ -408,7 +418,7 @@ No auth required. } ``` -> Note: the example is partial; the real response includes historical Claude 1.x/2.x/3.x/4.x IDs and common aliases. +> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases. ### `POST /anthropic/v1/messages` diff --git a/API.md b/API.md index e0f4a6e3..6c73b052 100644 --- a/API.md +++ b/API.md @@ -173,7 +173,7 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` ### `GET /v1/models` -无需鉴权。返回当前支持的模型列表。 +无需鉴权。返回当前支持的 DeepSeek 原生模型列表。 **响应示例**: @@ -184,11 +184,21 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` {"id": "deepseek-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, {"id": "deepseek-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, {"id": "deepseek-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, - {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} + {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-expert-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-vision-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} ] } ``` +> 说明:`/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID;常见 alias 仅用于请求入参解析,不会在该接口中单独展开返回。 + ### 模型 alias 解析策略 对 `chat` / `responses` / `embeddings` 的 `model` 字段采用“宽进严出”: @@ -211,7 +221,7 @@ Content-Type: application/json | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | -| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-4o`、`gpt-5-codex`、`o3`、`claude-sonnet-4-5`) | +| `model` | string | ✅ | 支持 DeepSeek 原生模型 + 常见 alias(如 `gpt-4o`、`gpt-5-codex`、`o3`、`claude-sonnet-4-5`、`gemini-2.5-pro` 等) | | `messages` | array | ✅ | OpenAI 风格消息数组 | | `stream` | boolean | ❌ | 默认 `false` | | `tools` | array | ❌ | Function Calling 定义 | @@ -414,7 +424,7 @@ data: [DONE] } ``` -> 说明:示例仅展示部分模型;实际返回包含 Claude 1.x/2.x/3.x/4.x 历史模型 ID 与常见别名。 +> 说明:示例仅展示部分模型;实际返回除当前主别名外,还包含 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见别名。 ### `POST /anthropic/v1/messages` diff --git a/README.MD b/README.MD index f85027d8..4fda9826 100644 --- a/README.MD +++ b/README.MD @@ -120,26 +120,35 @@ flowchart LR ## 模型支持 -### OpenAI 接口 - -| 模型 | thinking | search | -| --- | --- | --- | -| `deepseek-chat` | ❌ | ❌ | -| `deepseek-reasoner` | ✅ | ❌ | -| `deepseek-chat-search` | ❌ | ✅ | -| `deepseek-reasoner-search` | ✅ | ✅ | - -### Claude 接口 - -| 模型 | 默认映射 | +### OpenAI 接口(`GET /v1/models`) + +| 模型类型 | 模型 ID | thinking | search | +| --- | --- | --- | --- | +| default | `deepseek-chat` | ❌ | ❌ | +| default | `deepseek-reasoner` | ✅ | ❌ | +| default | `deepseek-chat-search` | ❌ | ✅ | +| default | `deepseek-reasoner-search` | ✅ | ✅ | +| expert | `deepseek-expert-chat` | ❌ | ❌ | +| expert | `deepseek-expert-reasoner` | ✅ | ❌ | +| expert | `deepseek-expert-chat-search` | ❌ | ✅ | +| expert | `deepseek-expert-reasoner-search` | ✅ | ✅ | +| vision | `deepseek-vision-chat` | ❌ | ❌ | +| vision | `deepseek-vision-reasoner` | ✅ | ❌ | +| vision | `deepseek-vision-chat-search` | ❌ | ✅ | +| vision | `deepseek-vision-reasoner-search` | ✅ | ✅ | + +除原生模型外,也支持常见 alias 输入(如 `gpt-4o`、`gpt-5-codex`、`o3`、`claude-sonnet-4-5`、`gemini-2.5-pro` 等),但 `/v1/models` 返回的是规范化后的 DeepSeek 原生模型 ID。 + +### Claude 接口(`GET /anthropic/v1/models`) + +| 当前常用模型 | 默认映射 | | --- | --- | | `claude-sonnet-4-5` | `deepseek-chat` | | `claude-haiku-4-5`(兼容 `claude-3-5-haiku-latest`) | `deepseek-chat` | | `claude-opus-4-6` | `deepseek-reasoner` | 可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。 -另外,`/anthropic/v1/models` 现已包含 Claude 1.x/2.x/3.x/4.x 历史模型 ID 与常见别名,便于旧客户端直接兼容。 - +`/anthropic/v1/models` 除上述当前主别名外,还会返回 Claude 4.x snapshots,以及 3.x / 2.x / 1.x 历史模型 ID 与常见 alias,便于旧客户端直接兼容。 #### Claude Code 接入避坑(实测) @@ -154,6 +163,15 @@ Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 Deep ## 快速开始 +### 部署方式优先级建议 + +推荐按以下顺序选择部署方式: + +1. **下载 Release 构建包运行**:最省事,产物已编译完成,最适合大多数用户。 +2. **Docker / GHCR 镜像部署**:适合需要容器化、编排或云环境部署。 +3. **Vercel 部署**:适合已有 Vercel 环境且接受其平台约束的场景。 +4. **本地源码运行 / 自行编译**:适合开发、调试或需要自行修改代码的场景。 + ### 通用第一步(所有部署方式) 把 `config.json` 作为唯一配置源(推荐做法): @@ -167,29 +185,19 @@ cp config.example.json config.json - 本地运行:直接读取 `config.json` - Docker / Vercel:由 `config.json` 生成 `DS2API_CONFIG_JSON`(Base64)注入环境变量,也可以直接写原始 JSON -### 方式一:本地运行 +### 方式一:下载 Release 构建包 -**前置要求**:Go 1.26+,Node.js `20.19+` 或 `22.12+`(仅在需要构建 WebUI 时) +每次发布 Release 时,GitHub Actions 会自动构建多平台二进制包: ```bash -# 1. 克隆仓库 -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 2. 配置 +# 下载对应平台的压缩包后 +tar -xzf ds2api__linux_amd64.tar.gz +cd ds2api__linux_amd64 cp config.example.json config.json -# 编辑 config.json,填入你的 DeepSeek 账号信息和 API key - -# 3. 启动 -go run ./cmd/ds2api +# 编辑 config.json +./ds2api ``` -默认本地访问地址:`http://127.0.0.1:5001` - -服务实际绑定:`0.0.0.0:5001`,因此同一局域网设备通常也可以通过你的内网 IP 访问。 - -> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js)。你也可以手动构建:`./scripts/build-webui.sh` - ### 方式二:Docker 运行 ```bash @@ -243,35 +251,28 @@ base64 < config.json | tr -d '\n' 详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。 -### 方式四:下载 Release 构建包 +### 方式四:本地源码运行 -每次发布 Release 时,GitHub Actions 会自动构建多平台二进制包: +**前置要求**:Go 1.26+,Node.js `20.19+` 或 `22.12+`(仅在需要构建 WebUI 时) ```bash -# 下载对应平台的压缩包后 -tar -xzf ds2api__linux_amd64.tar.gz -cd ds2api__linux_amd64 -cp config.example.json config.json -# 编辑 config.json -./ds2api -``` - -### 方式五:OpenCode CLI 接入 +# 1. 克隆仓库 +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api -1. 复制示例配置: +# 2. 配置 +cp config.example.json config.json +# 编辑 config.json,填入你的 DeepSeek 账号信息和 API key -```bash -cp opencode.json.example opencode.json +# 3. 启动 +go run ./cmd/ds2api ``` -2. 编辑 `opencode.json`: -- 将 `baseURL` 改为你的 DS2API 地址(例如 `https://your-domain.com/v1`) -- 将 `apiKey` 改为你的 DS2API key(对应 `config.keys`) +默认本地访问地址:`http://127.0.0.1:5001` -3. 在项目目录启动 OpenCode CLI(按你的安装方式运行 `opencode`)。 +服务实际绑定:`0.0.0.0:5001`,因此同一局域网设备通常也可以通过你的内网 IP 访问。 -> 建议优先使用 OpenAI 兼容路径(`/v1/*`),即示例里的 `@ai-sdk/openai-compatible` provider。 -> 若客户端支持 `wire_api`,可分别测试 `responses` 与 `chat`,DS2API 两条链路都兼容。 +> **WebUI 自动构建**:本地首次启动时,若 `static/admin` 不存在,会自动尝试执行 `npm ci`(仅在缺少依赖时)和 `npm run build -- --outDir static/admin --emptyOutDir`(需要本机有 Node.js)。你也可以手动构建:`./scripts/build-webui.sh` ## 配置说明 diff --git a/README.en.md b/README.en.md index 7c07e9db..6eafc67e 100644 --- a/README.en.md +++ b/README.en.md @@ -118,26 +118,35 @@ For the full module-by-module architecture and directory responsibilities, see [ ## Model Support -### OpenAI Endpoint - -| Model | thinking | search | -| --- | --- | --- | -| `deepseek-chat` | ❌ | ❌ | -| `deepseek-reasoner` | ✅ | ❌ | -| `deepseek-chat-search` | ❌ | ✅ | -| `deepseek-reasoner-search` | ✅ | ✅ | - -### Claude Endpoint - -| Model | Default Mapping | +### OpenAI Endpoint (`GET /v1/models`) + +| Family | Model ID | thinking | search | +| --- | --- | --- | --- | +| default | `deepseek-chat` | ❌ | ❌ | +| default | `deepseek-reasoner` | ✅ | ❌ | +| default | `deepseek-chat-search` | ❌ | ✅ | +| default | `deepseek-reasoner-search` | ✅ | ✅ | +| expert | `deepseek-expert-chat` | ❌ | ❌ | +| expert | `deepseek-expert-reasoner` | ✅ | ❌ | +| expert | `deepseek-expert-chat-search` | ❌ | ✅ | +| expert | `deepseek-expert-reasoner-search` | ✅ | ✅ | +| vision | `deepseek-vision-chat` | ❌ | ❌ | +| vision | `deepseek-vision-reasoner` | ✅ | ❌ | +| vision | `deepseek-vision-chat-search` | ❌ | ✅ | +| vision | `deepseek-vision-reasoner-search` | ✅ | ✅ | + +Besides native IDs, DS2API also accepts common aliases as input (for example `gpt-4o`, `gpt-5-codex`, `o3`, `claude-sonnet-4-5`, `gemini-2.5-pro`), but `/v1/models` returns normalized DeepSeek native model IDs. + +### Claude Endpoint (`GET /anthropic/v1/models`) + +| Current common model | Default Mapping | | --- | --- | | `claude-sonnet-4-5` | `deepseek-chat` | | `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`) | `deepseek-chat` | | `claude-opus-4-6` | `deepseek-reasoner` | Override mapping via `claude_mapping` or `claude_model_mapping` in config. -In addition, `/anthropic/v1/models` now includes historical Claude 1.x/2.x/3.x/4.x IDs and common aliases for legacy client compatibility. - +Besides the current primary aliases above, `/anthropic/v1/models` also returns Claude 4.x snapshots plus historical 3.x / 2.x / 1.x IDs and common aliases for legacy client compatibility. #### Claude Code integration pitfalls (validated) @@ -152,6 +161,15 @@ The Gemini adapter maps model names to DeepSeek native models via `model_aliases ## Quick Start +### Recommended deployment priority + +Recommended order when choosing a deployment method: + +1. **Download and run release binaries**: the easiest path for most users because the artifacts are already built. +2. **Docker / GHCR image deployment**: suitable for containerized, orchestrated, or cloud environments. +3. **Vercel deployment**: suitable if you already use Vercel and accept its platform constraints. +4. **Run from source / build locally**: suitable for development, debugging, or when you need to modify the code yourself. + ### Universal First Step (all deployment modes) Use `config.json` as the single source of truth (recommended): @@ -165,47 +183,37 @@ Recommended per deployment mode: - Local run: read `config.json` directly - Docker / Vercel: generate Base64 from `config.json` and inject as `DS2API_CONFIG_JSON`, or paste raw JSON directly -### Option 1: Local Run +### Option 1: Download Release Binaries -**Prerequisites**: Go 1.26+, Node.js `20.19+` or `22.12+` (only if building WebUI locally) +GitHub Actions automatically builds multi-platform archives on each Release: ```bash -# 1. Clone -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 2. Configure +# After downloading the archive for your platform +tar -xzf ds2api__linux_amd64.tar.gz +cd ds2api__linux_amd64 cp config.example.json config.json -# Edit config.json with your DeepSeek account info and API keys - -# 3. Start -go run ./cmd/ds2api +# Edit config.json +./ds2api ``` -Default local URL: `http://127.0.0.1:5001` +### Option 2: Docker / GHCR -The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usually reach it through your private IP as well. - -> **WebUI auto-build**: On first local startup, if `static/admin` is missing, DS2API will auto-run `npm ci` (only when dependencies are missing) and `npm run build -- --outDir static/admin --emptyOutDir` (requires Node.js). You can also build manually: `./scripts/build-webui.sh` +```bash +# Pull prebuilt image +docker pull ghcr.io/cjackhwang/ds2api:latest -### Option 2: Docker +# Or run a pinned version +# docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 -```bash -# 1. Prepare env file and config file +# Prepare env file and config file cp .env.example .env cp config.example.json config.json -# 2. Edit .env (at least set DS2API_ADMIN_KEY; optionally set DS2API_HOST_PORT to change the host port) -# DS2API_ADMIN_KEY=replace-with-a-strong-secret - -# 3. Start +# Start with compose docker-compose up -d - -# 4. View logs -docker-compose logs -f ``` -The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). +The default `docker-compose.yml` uses `ghcr.io/cjackhwang/ds2api:latest` and maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). Rebuild after updates: `docker-compose up -d --build` @@ -241,35 +249,28 @@ base64 < config.json | tr -d '\n' For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md). -### Option 4: Download Release Binaries +### Option 4: Local Run -GitHub Actions automatically builds multi-platform archives on each Release: +**Prerequisites**: Go 1.26+, Node.js `20.19+` or `22.12+` (only if building WebUI locally) ```bash -# After downloading the archive for your platform -tar -xzf ds2api__linux_amd64.tar.gz -cd ds2api__linux_amd64 -cp config.example.json config.json -# Edit config.json -./ds2api -``` - -### Option 5: OpenCode CLI +# 1. Clone +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api -1. Copy the example config: +# 2. Configure +cp config.example.json config.json +# Edit config.json with your DeepSeek account info and API keys -```bash -cp opencode.json.example opencode.json +# 3. Start +go run ./cmd/ds2api ``` -2. Edit `opencode.json`: -- Set `baseURL` to your DS2API endpoint (for example, `https://your-domain.com/v1`) -- Set `apiKey` to your DS2API key (from `config.keys`) +Default local URL: `http://127.0.0.1:5001` -3. Start OpenCode CLI in the project directory (run `opencode` using your installed method). +The server actually binds to `0.0.0.0:5001`, so devices on the same LAN can usually reach it through your private IP as well. -> Recommended: use the OpenAI-compatible path (`/v1/*`) via `@ai-sdk/openai-compatible` as shown in the example. -> If your client supports `wire_api`, test both `responses` and `chat`; DS2API supports both paths. +> **WebUI auto-build**: On first local startup, if `static/admin` is missing, DS2API will auto-run `npm ci` (only when dependencies are missing) and `npm run build -- --outDir static/admin --emptyOutDir` (requires Node.js). You can also build manually: `./scripts/build-webui.sh` ## Configuration diff --git a/VERSION b/VERSION index 0fa4ae48..fbcbf738 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.0 \ No newline at end of file +3.4.0 \ No newline at end of file diff --git a/docs/DEPLOY.en.md b/docs/DEPLOY.en.md index 2dc2d512..2bd6bbdf 100644 --- a/docs/DEPLOY.en.md +++ b/docs/DEPLOY.en.md @@ -10,11 +10,12 @@ Doc map: [Index](./README.md) | [Architecture](./ARCHITECTURE.en.md) | [API](../ ## Table of Contents +- [Recommended deployment priority](#recommended-deployment-priority) - [Prerequisites](#0-prerequisites) -- [1. Local Run](#1-local-run) -- [2. Docker Deployment](#2-docker-deployment) +- [1. Download Release Binaries](#1-download-release-binaries) +- [2. Docker / GHCR Deployment](#2-docker--ghcr-deployment) - [3. Vercel Deployment](#3-vercel-deployment) -- [4. Download Release Binaries](#4-download-release-binaries) +- [4. Local Run from Source](#4-local-run-from-source) - [5. Reverse Proxy (Nginx)](#5-reverse-proxy-nginx) - [6. Linux systemd Service](#6-linux-systemd-service) - [7. Post-Deploy Checks](#7-post-deploy-checks) @@ -22,6 +23,17 @@ Doc map: [Index](./README.md) | [Architecture](./ARCHITECTURE.en.md) | [API](../ --- +## Recommended deployment priority + +Recommended order when choosing a deployment method: + +1. **Download and run release binaries**: the easiest path for most users because the artifacts are already built. +2. **Docker / GHCR image deployment**: suitable for containerized, orchestrated, or cloud environments. +3. **Vercel deployment**: suitable if you already use Vercel and accept its platform constraints. +4. **Run from source / build locally**: suitable for development, debugging, or when you need to modify the code yourself. + +--- + ## 0. Prerequisites | Dependency | Minimum Version | Notes | @@ -48,70 +60,59 @@ Use `config.json` as the single source of truth: --- -## 1. Local Run +## 1. Download Release Binaries -### 1.1 Basic Steps - -```bash -# Clone -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# Copy and edit config -cp config.example.json config.json -# Open config.json and fill in: -# - keys: your API access keys -# - accounts: DeepSeek accounts (email or mobile + password) - -# Start -go run ./cmd/ds2api -``` - -Default local access URL: `http://127.0.0.1:5001`; the server actually binds to `0.0.0.0:5001` (override with `PORT`). +Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml` -### 1.2 WebUI Build +- **Trigger**: only on Release `published` (no build on normal push) +- **Outputs**: multi-platform binary archives + `sha256sums.txt` +- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`) -On first local startup, if `static/admin/` is missing, DS2API will automatically attempt to build the WebUI (requires Node.js/npm; when dependencies are missing it runs `npm ci` first, then `npm run build -- --outDir static/admin --emptyOutDir`). +| Platform | Architecture | Format | +| --- | --- | --- | +| Linux | amd64, arm64 | `.tar.gz` | +| macOS | amd64, arm64 | `.tar.gz` | +| Windows | amd64 | `.zip` | -Manual build: +Each archive includes: -```bash -./scripts/build-webui.sh -``` +- `ds2api` executable (`ds2api.exe` on Windows) +- `static/admin/` (built WebUI assets) +- `config.example.json`, `.env.example` +- `README.MD`, `README.en.md`, `LICENSE` -Or step by step: +### Usage ```bash -cd webui -npm install -npm run build -# Output goes to static/admin/ -``` - -Control auto-build via environment variable: +# 1. Download the archive for your platform +# 2. Extract +tar -xzf ds2api__linux_amd64.tar.gz +cd ds2api__linux_amd64 -```bash -# Disable auto-build -DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api +# 3. Configure +cp config.example.json config.json +# Edit config.json -# Force enable auto-build -DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api +# 4. Start +./ds2api ``` -### 1.3 Compile to Binary +### Maintainer Release Flow -```bash -go build -o ds2api ./cmd/ds2api -./ds2api -``` +1. Create and publish a GitHub Release (with tag, for example `vX.Y.Z`) +2. Wait for the `Release Artifacts` workflow to complete +3. Download the matching archive from Release Assets --- -## 2. Docker Deployment +## 2. Docker / GHCR Deployment ### 2.1 Basic Steps ```bash +# Pull prebuilt image +docker pull ghcr.io/cjackhwang/ds2api:latest + # Copy env template and config file cp .env.example .env cp config.example.json config.json @@ -128,7 +129,13 @@ docker-compose up -d docker-compose logs -f ``` -The default `docker-compose.yml` maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). +The default `docker-compose.yml` directly uses `ghcr.io/cjackhwang/ds2api:latest` and maps host port `6011` to container port `5001`. If you want `5001` exposed directly, set `DS2API_HOST_PORT=5001` (or adjust the `ports` mapping). + +If you want a pinned version instead of `latest`, you can also pull a specific tag directly: + +```bash +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 +``` ### 2.2 Update @@ -350,57 +357,61 @@ If API responses return Vercel HTML `Authentication Required`: --- -## 4. Download Release Binaries +## 4. Local Run from Source -Built-in GitHub Actions workflow: `.github/workflows/release-artifacts.yml` +### 4.1 Basic Steps -- **Trigger**: only on Release `published` (no build on normal push) -- **Outputs**: multi-platform binary archives + `sha256sums.txt` -- **Container publishing**: GHCR only (`ghcr.io/cjackhwang/ds2api`) +```bash +# Clone +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api -| Platform | Architecture | Format | -| --- | --- | --- | -| Linux | amd64, arm64 | `.tar.gz` | -| macOS | amd64, arm64 | `.tar.gz` | -| Windows | amd64 | `.zip` | +# Copy and edit config +cp config.example.json config.json +# Open config.json and fill in: +# - keys: your API access keys +# - accounts: DeepSeek accounts (email or mobile + password) -Each archive includes: +# Start +go run ./cmd/ds2api +``` -- `ds2api` executable (`ds2api.exe` on Windows) -- `static/admin/` (built WebUI assets) -- `config.example.json`, `.env.example` -- `README.MD`, `README.en.md`, `LICENSE` +Default local access URL: `http://127.0.0.1:5001`; the server actually binds to `0.0.0.0:5001` (override with `PORT`). -### Usage +### 4.2 WebUI Build -```bash -# 1. Download the archive for your platform -# 2. Extract -tar -xzf ds2api__linux_amd64.tar.gz -cd ds2api__linux_amd64 +On first local startup, if `static/admin/` is missing, DS2API will automatically attempt to build the WebUI (requires Node.js/npm; when dependencies are missing it runs `npm ci` first, then `npm run build -- --outDir static/admin --emptyOutDir`). -# 3. Configure -cp config.example.json config.json -# Edit config.json +Manual build: -# 4. Start -./ds2api +```bash +./scripts/build-webui.sh ``` -### Maintainer Release Flow +Or step by step: -1. Create and publish a GitHub Release (with tag, for example `vX.Y.Z`) -2. Wait for the `Release Artifacts` workflow to complete -3. Download the matching archive from Release Assets +```bash +cd webui +npm install +npm run build +# Output goes to static/admin/ +``` -### Pull from GHCR (Optional) +Control auto-build via environment variable: ```bash -# latest -docker pull ghcr.io/cjackhwang/ds2api:latest +# Disable auto-build +DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api -# specific version (example) -docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 +# Force enable auto-build +DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api +``` + +### 4.3 Compile to Binary + +```bash +go build -o ds2api ./cmd/ds2api +./ds2api ``` --- diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 1855d52b..cf2b67c0 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -10,11 +10,12 @@ ## 目录 +- [部署方式优先级建议](#部署方式优先级建议) - [前置要求](#0-前置要求) -- [一、本地运行](#一本地运行) -- [二、Docker 部署](#二docker-部署) +- [一、下载 Release 构建包](#一下载-release-构建包) +- [二、Docker / GHCR 部署](#二docker--ghcr-部署) - [三、Vercel 部署](#三vercel-部署) -- [四、下载 Release 构建包](#四下载-release-构建包) +- [四、本地源码运行](#四本地源码运行) - [五、反向代理(Nginx)](#五反向代理nginx) - [六、Linux systemd 服务化](#六linux-systemd-服务化) - [七、部署后检查](#七部署后检查) @@ -22,6 +23,17 @@ --- +## 部署方式优先级建议 + +推荐按以下顺序选择部署方式: + +1. **下载 Release 构建包运行**:最省事,产物已编译完成,最适合大多数用户。 +2. **Docker / GHCR 镜像部署**:适合需要容器化、编排或云环境部署。 +3. **Vercel 部署**:适合已有 Vercel 环境且接受其平台约束的场景。 +4. **本地源码运行 / 自行编译**:适合开发、调试或需要自行修改代码的场景。 + +--- + ## 0. 前置要求 | 依赖 | 最低版本 | 说明 | @@ -48,70 +60,59 @@ cp config.example.json config.json --- -## 一、本地运行 +## 一、下载 Release 构建包 -### 1.1 基本步骤 - -```bash -# 克隆仓库 -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 复制并编辑配置 -cp config.example.json config.json -# 使用你喜欢的编辑器打开 config.json,填入: -# - keys: 你的 API 访问密钥 -# - accounts: DeepSeek 账号(email 或 mobile + password) - -# 启动服务 -go run ./cmd/ds2api -``` - -默认本地访问地址是 `http://127.0.0.1:5001`;服务实际绑定 `0.0.0.0:5001`,可通过 `PORT` 环境变量覆盖。 +仓库内置 GitHub Actions 工作流:`.github/workflows/release-artifacts.yml` -### 1.2 WebUI 构建 +- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建) +- **构建产物**:多平台二进制压缩包 + `sha256sums.txt` +- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`) -本地首次启动时,若 `static/admin/` 不存在,服务会自动尝试构建 WebUI(需要 Node.js/npm;缺依赖时会先执行 `npm ci`,再执行 `npm run build -- --outDir static/admin --emptyOutDir`)。 +| 平台 | 架构 | 文件格式 | +| --- | --- | --- | +| Linux | amd64, arm64 | `.tar.gz` | +| macOS | amd64, arm64 | `.tar.gz` | +| Windows | amd64 | `.zip` | -你也可以手动构建: +每个压缩包包含: -```bash -./scripts/build-webui.sh -``` +- `ds2api` 可执行文件(Windows 为 `ds2api.exe`) +- `static/admin/`(WebUI 构建产物) +- `config.example.json`、`.env.example` +- `README.MD`、`README.en.md`、`LICENSE` -或手动执行: +### 使用步骤 ```bash -cd webui -npm install -npm run build -# 产物输出到 static/admin/ -``` - -通过环境变量控制自动构建行为: +# 1. 下载对应平台的压缩包 +# 2. 解压 +tar -xzf ds2api__linux_amd64.tar.gz +cd ds2api__linux_amd64 -```bash -# 强制关闭自动构建 -DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api +# 3. 配置 +cp config.example.json config.json +# 编辑 config.json -# 强制开启自动构建 -DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api +# 4. 启动 +./ds2api ``` -### 1.3 编译为二进制文件 +### 维护者发布步骤 -```bash -go build -o ds2api ./cmd/ds2api -./ds2api -``` +1. 在 GitHub 创建并发布 Release(带 tag,如 `vX.Y.Z`) +2. 等待 Actions 工作流 `Release Artifacts` 完成 +3. 在 Release 的 Assets 下载对应平台压缩包 --- -## 二、Docker 部署 +## 二、Docker / GHCR 部署 ### 2.1 基本步骤 ```bash +# 拉取预编译镜像 +docker pull ghcr.io/cjackhwang/ds2api:latest + # 复制环境变量模板和配置文件 cp .env.example .env cp config.example.json config.json @@ -128,7 +129,13 @@ docker-compose up -d docker-compose logs -f ``` -默认 `docker-compose.yml` 会把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 +默认 `docker-compose.yml` 直接使用 `ghcr.io/cjackhwang/ds2api:latest`,并把宿主机 `6011` 映射到容器内的 `5001`。如果你希望直接对外暴露 `5001`,请设置 `DS2API_HOST_PORT=5001`(或者手动调整 `ports` 配置)。 + +如需固定版本,也可以直接拉取指定 tag: + +```bash +docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 +``` ### 2.2 更新 @@ -350,57 +357,61 @@ No Output Directory named "public" found after the Build completed. --- -## 四、下载 Release 构建包 +## 四、本地源码运行 -仓库内置 GitHub Actions 工作流:`.github/workflows/release-artifacts.yml` +### 4.1 基本步骤 -- **触发条件**:仅在 Release `published` 时触发(普通 push 不会构建) -- **构建产物**:多平台二进制压缩包 + `sha256sums.txt` -- **容器镜像发布**:仅发布到 GHCR(`ghcr.io/cjackhwang/ds2api`) +```bash +# 克隆仓库 +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api -| 平台 | 架构 | 文件格式 | -| --- | --- | --- | -| Linux | amd64, arm64 | `.tar.gz` | -| macOS | amd64, arm64 | `.tar.gz` | -| Windows | amd64 | `.zip` | +# 复制并编辑配置 +cp config.example.json config.json +# 使用你喜欢的编辑器打开 config.json,填入: +# - keys: 你的 API 访问密钥 +# - accounts: DeepSeek 账号(email 或 mobile + password) -每个压缩包包含: +# 启动服务 +go run ./cmd/ds2api +``` -- `ds2api` 可执行文件(Windows 为 `ds2api.exe`) -- `static/admin/`(WebUI 构建产物) -- `config.example.json`、`.env.example` -- `README.MD`、`README.en.md`、`LICENSE` +默认本地访问地址是 `http://127.0.0.1:5001`;服务实际绑定 `0.0.0.0:5001`,可通过 `PORT` 环境变量覆盖。 -### 使用步骤 +### 4.2 WebUI 构建 -```bash -# 1. 下载对应平台的压缩包 -# 2. 解压 -tar -xzf ds2api__linux_amd64.tar.gz -cd ds2api__linux_amd64 +本地首次启动时,若 `static/admin/` 不存在,服务会自动尝试构建 WebUI(需要 Node.js/npm;缺依赖时会先执行 `npm ci`,再执行 `npm run build -- --outDir static/admin --emptyOutDir`)。 -# 3. 配置 -cp config.example.json config.json -# 编辑 config.json +你也可以手动构建: -# 4. 启动 -./ds2api +```bash +./scripts/build-webui.sh ``` -### 维护者发布步骤 +或手动执行: -1. 在 GitHub 创建并发布 Release(带 tag,如 `vX.Y.Z`) -2. 等待 Actions 工作流 `Release Artifacts` 完成 -3. 在 Release 的 Assets 下载对应平台压缩包 +```bash +cd webui +npm install +npm run build +# 产物输出到 static/admin/ +``` -### 拉取 GHCR 镜像(可选) +通过环境变量控制自动构建行为: ```bash -# latest -docker pull ghcr.io/cjackhwang/ds2api:latest +# 强制关闭自动构建 +DS2API_AUTO_BUILD_WEBUI=false go run ./cmd/ds2api -# 指定版本(示例) -docker pull ghcr.io/cjackhwang/ds2api:v3.0.0 +# 强制开启自动构建 +DS2API_AUTO_BUILD_WEBUI=true go run ./cmd/ds2api +``` + +### 4.3 编译为二进制文件 + +```bash +go build -o ds2api ./cmd/ds2api +./ds2api ``` --- diff --git a/go.mod b/go.mod index 1471913b..2613f89b 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.52.0 golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/adapter/claude/proxy_vercel_test.go b/internal/adapter/claude/proxy_vercel_test.go index 9a441e9d..18f0f98e 100644 --- a/internal/adapter/claude/proxy_vercel_test.go +++ b/internal/adapter/claude/proxy_vercel_test.go @@ -34,11 +34,13 @@ func (s openAIProxyStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) type openAIProxyCaptureStub struct { seenModel string + seenReq map[string]any } func (s *openAIProxyCaptureStub) ChatCompletions(w http.ResponseWriter, r *http.Request) { var req map[string]any _ = json.NewDecoder(r.Body).Decode(&req) + s.seenReq = req if m, ok := req["model"].(string); ok { s.seenModel = m } @@ -84,3 +86,33 @@ func TestClaudeProxyViaOpenAIPreservesClaudeMapping(t *testing.T) { t.Fatalf("expected mapped proxy model deepseek-reasoner, got %q", got) } } + +func TestClaudeProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) { + openAI := &openAIProxyCaptureStub{} + h := &Handler{OpenAI: openAI} + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"QUJDRA=="}}]}],"stream":false}`)) + rec := httptest.NewRecorder() + + h.Messages(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", rec.Code, rec.Body.String()) + } + messages, _ := openAI.seenReq["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("expected one translated message, got %#v", openAI.seenReq) + } + msg, _ := messages[0].(map[string]any) + content, _ := msg["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected translated content blocks, got %#v", msg) + } + imageBlock, _ := content[1].(map[string]any) + if strings.TrimSpace(asString(imageBlock["type"])) != "image_url" { + t.Fatalf("expected image_url block, got %#v", imageBlock) + } + imageURL, _ := imageBlock["image_url"].(map[string]any) + if !strings.HasPrefix(strings.TrimSpace(asString(imageURL["url"])), "data:image/png;base64,") { + t.Fatalf("expected translated data url, got %#v", imageBlock) + } +} diff --git a/internal/adapter/claude/standard_request.go b/internal/adapter/claude/standard_request.go index 7b16c967..d73ffdae 100644 --- a/internal/adapter/claude/standard_request.go +++ b/internal/adapter/claude/standard_request.go @@ -36,7 +36,7 @@ func normalizeClaudeRequest(store ConfigReader, req map[string]any) (claudeNorma thinkingEnabled = false searchEnabled = false } - finalPrompt := deepseek.MessagesPrepare(toMessageMaps(dsPayload["messages"])) + finalPrompt := deepseek.MessagesPrepareWithThinking(toMessageMaps(dsPayload["messages"]), thinkingEnabled) toolNames := extractClaudeToolNames(toolsRequested) if len(toolNames) == 0 && len(toolsRequested) > 0 { toolNames = []string{"__any_tool__"} diff --git a/internal/adapter/gemini/convert_request.go b/internal/adapter/gemini/convert_request.go index 34eb2a24..5a9ff953 100644 --- a/internal/adapter/gemini/convert_request.go +++ b/internal/adapter/gemini/convert_request.go @@ -28,7 +28,7 @@ func normalizeGeminiRequest(store ConfigReader, routeModel string, req map[strin } toolsRaw := convertGeminiTools(req["tools"]) - finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "") + finalPrompt, toolNames := openai.BuildPromptForAdapter(messagesRaw, toolsRaw, "", thinkingEnabled) passThrough := collectGeminiPassThrough(req) return util.StandardRequest{ diff --git a/internal/adapter/gemini/handler_test.go b/internal/adapter/gemini/handler_test.go index b7aea1bb..94a1a4ec 100644 --- a/internal/adapter/gemini/handler_test.go +++ b/internal/adapter/gemini/handler_test.go @@ -82,11 +82,17 @@ func (s geminiOpenAIErrorStub) ChatCompletions(w http.ResponseWriter, _ *http.Re } type geminiOpenAISuccessStub struct { - stream bool - body string + stream bool + body string + seenReq map[string]any } -func (s geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, _ *http.Request) { +func (s *geminiOpenAISuccessStub) ChatCompletions(w http.ResponseWriter, r *http.Request) { + if r != nil { + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + s.seenReq = req + } if s.stream { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) @@ -144,7 +150,7 @@ func TestGeminiRoutesRegistered(t *testing.T) { func TestGenerateContentReturnsFunctionCallParts(t *testing.T) { h := &Handler{ Store: testGeminiConfig{}, - OpenAI: geminiOpenAISuccessStub{ + OpenAI: &geminiOpenAISuccessStub{ body: `{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"eval_javascript","arguments":"{\"code\":\"1+1\"}"}}]},"finish_reason":"tool_calls"}]}`, }, } @@ -184,7 +190,7 @@ func TestGenerateContentReturnsFunctionCallParts(t *testing.T) { } func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) { - h := &Handler{Store: testGeminiConfig{}, OpenAI: geminiOpenAISuccessStub{}} + h := &Handler{Store: testGeminiConfig{}, OpenAI: &geminiOpenAISuccessStub{}} r := chi.NewRouter() RegisterRoutes(r, h) @@ -217,7 +223,7 @@ func TestGenerateContentMixedToolSnippetAlsoTriggersFunctionCall(t *testing.T) { func TestStreamGenerateContentEmitsSSE(t *testing.T) { h := &Handler{ Store: testGeminiConfig{}, - OpenAI: geminiOpenAISuccessStub{stream: true}, + OpenAI: &geminiOpenAISuccessStub{stream: true}, } r := chi.NewRouter() RegisterRoutes(r, h) @@ -251,6 +257,39 @@ func TestStreamGenerateContentEmitsSSE(t *testing.T) { } } +func TestGeminiProxyTranslatesInlineImageToOpenAIDataURL(t *testing.T) { + openAI := &geminiOpenAISuccessStub{} + h := &Handler{Store: testGeminiConfig{}, OpenAI: openAI} + r := chi.NewRouter() + RegisterRoutes(r, h) + + body := `{"contents":[{"role":"user","parts":[{"text":"hello"},{"inlineData":{"mimeType":"image/png","data":"QUJDRA=="}}]}]}` + req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-pro:generateContent", strings.NewReader(body)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + messages, _ := openAI.seenReq["messages"].([]any) + if len(messages) != 1 { + t.Fatalf("expected one translated message, got %#v", openAI.seenReq) + } + msg, _ := messages[0].(map[string]any) + content, _ := msg["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected translated content blocks, got %#v", msg) + } + imageBlock, _ := content[1].(map[string]any) + if strings.TrimSpace(asString(imageBlock["type"])) != "image_url" { + t.Fatalf("expected image_url block, got %#v", imageBlock) + } + imageURL, _ := imageBlock["image_url"].(map[string]any) + if !strings.HasPrefix(strings.TrimSpace(asString(imageURL["url"])), "data:image/png;base64,") { + t.Fatalf("expected translated data url, got %#v", imageBlock) + } +} + func TestGenerateContentOpenAIProxyErrorUsesGeminiEnvelope(t *testing.T) { h := &Handler{ Store: testGeminiConfig{}, diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 80a94198..a5ff195f 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -98,6 +98,19 @@ func (s *chatStreamRuntime) sendDone() { } } +func (s *chatStreamRuntime) sendFailedChunk(status int, message, code string) { + s.sendChunk(map[string]any{ + "status_code": status, + "error": map[string]any{ + "message": message, + "type": openAIErrorType(status), + "code": code, + "param": nil, + }, + }) + s.sendDone() +} + func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) @@ -168,6 +181,21 @@ func (s *chatStreamRuntime) finalize(finishReason string) { if len(detected.Calls) > 0 || s.toolCallsEmitted { finishReason = "tool_calls" } + if len(detected.Calls) == 0 && !s.toolCallsEmitted && strings.TrimSpace(finalText) == "" { + status := http.StatusTooManyRequests + message := "Upstream model returned empty output." + code := "upstream_empty_output" + if strings.TrimSpace(finalThinking) != "" { + message = "Upstream model returned reasoning without visible output." + } + if finishReason == "content_filter" { + status = http.StatusBadRequest + message = "Upstream content filtered the response and returned no output." + code = "content_filter" + } + s.sendFailedChunk(status, message, code) + return + } usage := openaifmt.BuildChatUsage(s.finalPrompt, finalThinking, finalText) s.sendChunk(openaifmt.BuildChatStreamChunk( s.completionID, @@ -184,6 +212,9 @@ func (s *chatStreamRuntime) onParsed(parsed sse.LineResult) streamengine.ParsedD return streamengine.ParsedDecision{} } if parsed.ContentFilter { + if strings.TrimSpace(s.text.String()) == "" { + return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReason("content_filter")} + } return streamengine.ParsedDecision{Stop: true, StopReason: streamengine.StopReasonHandlerRequested} } if parsed.ErrorMessage != "" { diff --git a/internal/adapter/openai/deps.go b/internal/adapter/openai/deps.go index 22b1ff14..351a13c4 100644 --- a/internal/adapter/openai/deps.go +++ b/internal/adapter/openai/deps.go @@ -18,6 +18,7 @@ type AuthResolver interface { type DeepSeekCaller interface { CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) + UploadFile(ctx context.Context, a *auth.RequestAuth, req deepseek.UploadFileRequest, maxAttempts int) (*deepseek.UploadFileResult, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*deepseek.DeleteSessionResult, error) DeleteAllSessionsForToken(ctx context.Context, token string) error diff --git a/internal/adapter/openai/embeddings_handler.go b/internal/adapter/openai/embeddings_handler.go index ff61be08..48dfdd85 100644 --- a/internal/adapter/openai/embeddings_handler.go +++ b/internal/adapter/openai/embeddings_handler.go @@ -26,8 +26,13 @@ func (h *Handler) Embeddings(w http.ResponseWriter, r *http.Request) { } defer h.Auth.Release(a) + r.Body = http.MaxBytesReader(w, r.Body, openAIGeneralMaxSize) var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "too large") { + writeOpenAIError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } writeOpenAIError(w, http.StatusBadRequest, "invalid json") return } diff --git a/internal/adapter/openai/file_inline_upload.go b/internal/adapter/openai/file_inline_upload.go new file mode 100644 index 00000000..5955e815 --- /dev/null +++ b/internal/adapter/openai/file_inline_upload.go @@ -0,0 +1,382 @@ +package openai + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "mime" + "net/http" + "net/url" + "path/filepath" + "strings" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +const maxInlineFilesPerRequest = 50 + +type inlineFileUploadError struct { + status int + message string + err error +} + +func (e *inlineFileUploadError) Error() string { + if e == nil { + return "" + } + if strings.TrimSpace(e.message) != "" { + return e.message + } + if e.err != nil { + return e.err.Error() + } + return "inline file processing failed" +} + +type inlineUploadState struct { + ctx context.Context + handler *Handler + auth *auth.RequestAuth + uploadedByID map[string]string + uploadCount int +} + +type inlineDecodedFile struct { + Data []byte + ContentType string + Filename string + ReplacementType string +} + +func (h *Handler) preprocessInlineFileInputs(ctx context.Context, a *auth.RequestAuth, req map[string]any) error { + if h == nil || h.DS == nil || len(req) == 0 { + return nil + } + state := &inlineUploadState{ + ctx: ctx, + handler: h, + auth: a, + uploadedByID: map[string]string{}, + } + for _, key := range []string{"messages", "input", "attachments"} { + if raw, ok := req[key]; ok { + updated, err := state.walk(raw) + if err != nil { + return err + } + req[key] = updated + } + } + if refIDs := collectOpenAIRefFileIDs(req); len(refIDs) > 0 { + req["ref_file_ids"] = stringsToAnySlice(refIDs) + } + return nil +} + +func writeOpenAIInlineFileError(w http.ResponseWriter, err error) { + inlineErr, ok := err.(*inlineFileUploadError) + if !ok || inlineErr == nil { + writeOpenAIError(w, http.StatusInternalServerError, "Failed to process file input.") + return + } + status := inlineErr.status + if status == 0 { + status = http.StatusInternalServerError + } + message := strings.TrimSpace(inlineErr.message) + if message == "" { + message = "Failed to process file input." + } + writeOpenAIError(w, status, message) +} + +func (s *inlineUploadState) walk(raw any) (any, error) { + switch x := raw.(type) { + case []any: + out := make([]any, len(x)) + for i, item := range x { + updated, err := s.walk(item) + if err != nil { + return nil, err + } + out[i] = updated + } + return out, nil + case map[string]any: + if replacement, replaced, err := s.tryUploadBlock(x); replaced || err != nil { + return replacement, err + } + for _, key := range []string{"messages", "input", "attachments", "content", "files", "items", "data", "source", "file", "image_url"} { + if nested, ok := x[key]; ok { + updated, err := s.walk(nested) + if err != nil { + return nil, err + } + x[key] = updated + } + } + return x, nil + default: + return raw, nil + } +} + +func (s *inlineUploadState) tryUploadBlock(block map[string]any) (map[string]any, bool, error) { + decoded, ok, err := decodeOpenAIInlineFileBlock(block) + if err != nil { + return nil, true, &inlineFileUploadError{status: http.StatusBadRequest, message: err.Error(), err: err} + } + if !ok { + return nil, false, nil + } + if s.uploadCount >= maxInlineFilesPerRequest { + return nil, true, fmt.Errorf("exceeded maximum of %d inline files per request", maxInlineFilesPerRequest) + } + fileID, err := s.uploadInlineFile(decoded) + if err != nil { + return nil, true, &inlineFileUploadError{status: http.StatusInternalServerError, message: "Failed to upload inline file.", err: err} + } + s.uploadCount++ + replacement := map[string]any{ + "type": decoded.ReplacementType, + "file_id": fileID, + } + if decoded.Filename != "" { + replacement["filename"] = decoded.Filename + } + if decoded.ContentType != "" { + replacement["mime_type"] = decoded.ContentType + } + return replacement, true, nil +} + +func (s *inlineUploadState) uploadInlineFile(file inlineDecodedFile) (string, error) { + sum := sha256.Sum256(append([]byte(file.ContentType+"\x00"+file.Filename+"\x00"), file.Data...)) + cacheKey := fmt.Sprintf("%x", sum[:]) + if fileID, ok := s.uploadedByID[cacheKey]; ok && strings.TrimSpace(fileID) != "" { + return fileID, nil + } + contentType := strings.TrimSpace(file.ContentType) + if contentType == "" { + contentType = http.DetectContentType(file.Data) + } + result, err := s.handler.DS.UploadFile(s.ctx, s.auth, deepseek.UploadFileRequest{ + Filename: file.Filename, + ContentType: contentType, + Data: file.Data, + }, 3) + if err != nil { + return "", err + } + fileID := strings.TrimSpace(result.ID) + if fileID == "" { + return "", fmt.Errorf("upload succeeded without file id") + } + s.uploadedByID[cacheKey] = fileID + return fileID, nil +} + +func decodeOpenAIInlineFileBlock(block map[string]any) (inlineDecodedFile, bool, error) { + if block == nil { + return inlineDecodedFile{}, false, nil + } + if strings.TrimSpace(asString(block["file_id"])) != "" { + return inlineDecodedFile{}, false, nil + } + if nested, ok := block["file"].(map[string]any); ok { + decoded, matched, err := decodeOpenAIInlineFileBlock(nested) + if err != nil || !matched { + return decoded, matched, err + } + if decoded.Filename == "" { + decoded.Filename = pickInlineFilename(block, decoded.ContentType, defaultInlinePrefix(decoded.ReplacementType)) + } + return decoded, true, nil + } + blockType := strings.ToLower(strings.TrimSpace(asString(block["type"]))) + if raw, matched := extractInlineImageDataURL(block); matched { + data, contentType, err := decodeInlinePayload(raw, contentTypeFromMap(block)) + if err != nil { + return inlineDecodedFile{}, true, fmt.Errorf("invalid image input") + } + return inlineDecodedFile{ + Data: data, + ContentType: contentType, + Filename: pickInlineFilename(block, contentType, "image"), + ReplacementType: "input_image", + }, true, nil + } + if raw, matched := extractInlineFilePayload(block, blockType); matched { + data, contentType, err := decodeInlinePayload(raw, contentTypeFromMap(block)) + if err != nil { + return inlineDecodedFile{}, true, fmt.Errorf("invalid file input") + } + return inlineDecodedFile{ + Data: data, + ContentType: contentType, + Filename: pickInlineFilename(block, contentType, defaultInlinePrefix(blockType)), + ReplacementType: "input_file", + }, true, nil + } + return inlineDecodedFile{}, false, nil +} + +func extractInlineImageDataURL(block map[string]any) (string, bool) { + imageURL := block["image_url"] + switch x := imageURL.(type) { + case string: + if isDataURL(x) { + return strings.TrimSpace(x), true + } + case map[string]any: + if raw := strings.TrimSpace(asString(x["url"])); isDataURL(raw) { + return raw, true + } + } + if raw := strings.TrimSpace(asString(block["url"])); isDataURL(raw) { + return raw, true + } + return "", false +} + +func extractInlineFilePayload(block map[string]any, blockType string) (string, bool) { + for _, value := range []any{block["file_data"], block["base64"], block["data"]} { + if raw := strings.TrimSpace(asString(value)); raw != "" { + if strings.Contains(blockType, "file") || block["file_data"] != nil || block["filename"] != nil || block["file_name"] != nil || block["name"] != nil { + return raw, true + } + } + } + return "", false +} + +func decodeInlinePayload(raw string, explicitContentType string) ([]byte, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, "", fmt.Errorf("empty payload") + } + if isDataURL(raw) { + return decodeDataURL(raw, explicitContentType) + } + decoded, err := decodeBase64Flexible(raw) + if err != nil { + return nil, "", err + } + contentType := strings.TrimSpace(explicitContentType) + if contentType == "" && len(decoded) > 0 { + contentType = http.DetectContentType(decoded) + } + return decoded, contentType, nil +} + +func decodeDataURL(raw string, explicitContentType string) ([]byte, string, error) { + raw = strings.TrimSpace(raw) + if !isDataURL(raw) { + return nil, "", fmt.Errorf("unsupported data url") + } + header, payload, ok := strings.Cut(raw, ",") + if !ok { + return nil, "", fmt.Errorf("invalid data url") + } + meta := strings.TrimSpace(strings.TrimPrefix(header, "data:")) + contentType := strings.TrimSpace(explicitContentType) + if contentType == "" { + contentType = "application/octet-stream" + if meta != "" { + parts := strings.Split(meta, ";") + if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { + contentType = strings.TrimSpace(parts[0]) + } + } + } + if strings.Contains(strings.ToLower(meta), ";base64") { + decoded, err := decodeBase64Flexible(payload) + if err != nil { + return nil, "", err + } + return decoded, contentType, nil + } + decoded, err := url.PathUnescape(payload) + if err != nil { + return nil, "", err + } + return []byte(decoded), contentType, nil +} + +func decodeBase64Flexible(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + decoded, err := enc.DecodeString(raw) + if err == nil { + return decoded, nil + } + } + return nil, fmt.Errorf("invalid base64 payload") +} + +func contentTypeFromMap(block map[string]any) string { + for _, value := range []any{block["mime_type"], block["mimeType"], block["content_type"], block["contentType"], block["media_type"], block["mediaType"]} { + if contentType := strings.TrimSpace(asString(value)); contentType != "" { + return contentType + } + } + if imageURL, ok := block["image_url"].(map[string]any); ok { + for _, value := range []any{imageURL["mime_type"], imageURL["mimeType"], imageURL["content_type"], imageURL["contentType"]} { + if contentType := strings.TrimSpace(asString(value)); contentType != "" { + return contentType + } + } + } + return "" +} + +func pickInlineFilename(block map[string]any, contentType string, prefix string) string { + for _, value := range []any{block["filename"], block["file_name"], block["name"]} { + if name := strings.TrimSpace(asString(value)); name != "" { + return filepath.Base(name) + } + } + if prefix == "" { + prefix = "upload" + } + ext := ".bin" + if parsedType := strings.TrimSpace(contentType); parsedType != "" { + if comma := strings.Index(parsedType, ";"); comma >= 0 { + parsedType = strings.TrimSpace(parsedType[:comma]) + } + if exts, err := mime.ExtensionsByType(parsedType); err == nil && len(exts) > 0 && strings.TrimSpace(exts[0]) != "" { + ext = exts[0] + } + } + return prefix + ext +} + +func defaultInlinePrefix(blockType string) string { + blockType = strings.ToLower(strings.TrimSpace(blockType)) + if strings.Contains(blockType, "image") { + return "image" + } + return "upload" +} + +func isDataURL(raw string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "data:") +} + +func stringsToAnySlice(items []string) []any { + out := make([]any, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/adapter/openai/file_inline_upload_test.go b/internal/adapter/openai/file_inline_upload_test.go new file mode 100644 index 00000000..f1c7c813 --- /dev/null +++ b/internal/adapter/openai/file_inline_upload_test.go @@ -0,0 +1,274 @@ +package openai + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +type inlineUploadDSStub struct { + uploadCalls []deepseek.UploadFileRequest + lastCtx context.Context + completionReq map[string]any + createSession string + uploadErr error + completionResp *http.Response +} + +func (m *inlineUploadDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + if strings.TrimSpace(m.createSession) == "" { + return "session-id", nil + } + return m.createSession, nil +} + +func (m *inlineUploadDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "pow", nil +} + +func (m *inlineUploadDSStub) UploadFile(ctx context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { + m.lastCtx = ctx + m.uploadCalls = append(m.uploadCalls, req) + if m.uploadErr != nil { + return nil, m.uploadErr + } + return &deepseek.UploadFileResult{ + ID: "file-inline-1", + Filename: req.Filename, + Bytes: int64(len(req.Data)), + Status: "uploaded", + Purpose: req.Purpose, + }, nil +} + +func (m *inlineUploadDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, payload map[string]any, _ string, _ int) (*http.Response, error) { + m.completionReq = payload + if m.completionResp != nil { + return m.completionResp, nil + } + return makeOpenAISSEHTTPResponse( + `data: {"p":"response/content","v":"ok"}`, + `data: [DONE]`, + ), nil +} + +func (m *inlineUploadDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { + return &deepseek.DeleteSessionResult{Success: true}, nil +} + +func (m *inlineUploadDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + return nil +} + +func TestPreprocessInlineFileInputsReplacesDataURLAndCollectsRefFileIDs(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{DS: ds} + req := map[string]any{ + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}, + }, + }, + }, + }, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := h.preprocessInlineFileInputs(ctx, &auth.RequestAuth{DeepSeekToken: "token"}, req); err != nil { + t.Fatalf("preprocess failed: %v", err) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload, got %d", len(ds.uploadCalls)) + } + if ds.lastCtx != ctx { + t.Fatalf("expected upload to use request context") + } + if ds.uploadCalls[0].ContentType != "image/png" { + t.Fatalf("expected image/png, got %q", ds.uploadCalls[0].ContentType) + } + if ds.uploadCalls[0].Filename != "image.png" { + t.Fatalf("expected inferred filename image.png, got %q", ds.uploadCalls[0].Filename) + } + messages, _ := req["messages"].([]any) + first, _ := messages[0].(map[string]any) + content, _ := first["content"].([]any) + block, _ := content[0].(map[string]any) + if block["type"] != "input_image" { + t.Fatalf("expected input_image replacement, got %#v", block) + } + if block["file_id"] != "file-inline-1" { + t.Fatalf("expected file-inline-1 replacement id, got %#v", block) + } + refIDs, _ := req["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-inline-1" { + t.Fatalf("unexpected ref_file_ids: %#v", req["ref_file_ids"]) + } +} + +func TestPreprocessInlineFileInputsDeduplicatesIdenticalPayloads(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{DS: ds} + req := map[string]any{ + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,QUJDRA=="}}, + }, + }, + }, + } + + if err := h.preprocessInlineFileInputs(context.Background(), &auth.RequestAuth{DeepSeekToken: "token"}, req); err != nil { + t.Fatalf("preprocess failed: %v", err) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected deduplicated single upload, got %d", len(ds.uploadCalls)) + } + refIDs, _ := req["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-inline-1" { + t.Fatalf("unexpected ref_file_ids after dedupe: %#v", req["ref_file_ids"]) + } +} + +func TestChatCompletionsUploadsInlineFilesBeforeCompletion(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + reqBody := `{"model":"deepseek-chat","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.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + if ds.completionReq == nil { + t.Fatal("expected completion payload to be captured") + } + refIDs, _ := ds.completionReq["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-inline-1" { + t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"]) + } +} + +func TestResponsesUploadsInlineFilesBeforeCompletion(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + reqBody := `{"model":"deepseek-chat","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.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + refIDs, _ := ds.completionReq["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-inline-1" { + t.Fatalf("unexpected completion ref_file_ids: %#v", ds.completionReq["ref_file_ids"]) + } +} + +func TestChatCompletionsInlineUploadFailureReturnsBadRequest(t *testing.T) { + ds := &inlineUploadDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,%%%"}}]}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.ChatCompletions(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + if ds.completionReq != nil { + t.Fatalf("did not expect completion call on upload decode error") + } +} + +func TestResponsesInlineUploadFailureReturnsInternalServerError(t *testing.T) { + ds := &inlineUploadDSStub{uploadErr: errors.New("boom")} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + reqBody := `{"model":"deepseek-chat","input":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":false}` + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d body=%s", rec.Code, rec.Body.String()) + } + if ds.completionReq != nil { + t.Fatalf("did not expect completion call after upload failure") + } +} + +func TestVercelPrepareUploadsInlineFilesBeforeLeasePayload(t *testing.T) { + t.Setenv("VERCEL", "1") + t.Setenv("DS2API_VERCEL_INTERNAL_SECRET", "stream-secret") + ds := &inlineUploadDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":[{"type":"input_text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJDRA=="}}]}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?__stream_prepare=1", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("X-Ds2-Internal-Token", "stream-secret") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(ds.uploadCalls) != 1 { + t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls)) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) + } + payload, _ := out["payload"].(map[string]any) + if payload == nil { + t.Fatalf("expected payload in prepare response, got %#v", out) + } + refIDs, _ := payload["ref_file_ids"].([]any) + if len(refIDs) != 1 || refIDs[0] != "file-inline-1" { + t.Fatalf("unexpected payload ref_file_ids: %#v", payload["ref_file_ids"]) + } +} diff --git a/internal/adapter/openai/file_refs.go b/internal/adapter/openai/file_refs.go new file mode 100644 index 00000000..d1cef34c --- /dev/null +++ b/internal/adapter/openai/file_refs.go @@ -0,0 +1,94 @@ +package openai + +import "strings" + +func collectOpenAIRefFileIDs(req map[string]any) []string { + if len(req) == 0 { + return nil + } + out := make([]string, 0, 4) + seen := map[string]struct{}{} + for _, key := range []string{ + "ref_file_ids", + "file_ids", + "attachments", + "messages", + "input", + } { + raw := req[key] + if raw == nil { + continue + } + // Skip top-level strings for 'messages' and 'input' as they are likely plain text content, + // not file IDs. String file IDs are expected in 'ref_file_ids' or 'file_ids'. + if key == "messages" || key == "input" { + if _, ok := raw.(string); ok { + continue + } + } + appendOpenAIRefFileIDs(&out, seen, raw) + } + if len(out) == 0 { + return nil + } + return out +} + +func appendOpenAIRefFileIDs(out *[]string, seen map[string]struct{}, raw any) { + switch x := raw.(type) { + case string: + addOpenAIRefFileID(out, seen, x) + case []string: + for _, item := range x { + addOpenAIRefFileID(out, seen, item) + } + case []any: + for _, item := range x { + appendOpenAIRefFileIDs(out, seen, item) + } + case map[string]any: + if fileID := strings.TrimSpace(asString(x["file_id"])); fileID != "" { + addOpenAIRefFileID(out, seen, fileID) + } + if strings.Contains(strings.ToLower(strings.TrimSpace(asString(x["type"]))), "file") { + if fileID := strings.TrimSpace(asString(x["id"])); fileID != "" { + addOpenAIRefFileID(out, seen, fileID) + } + } + if fileMap, ok := x["file"].(map[string]any); ok { + if fileID := strings.TrimSpace(asString(fileMap["file_id"])); fileID != "" { + addOpenAIRefFileID(out, seen, fileID) + } + if fileID := strings.TrimSpace(asString(fileMap["id"])); fileID != "" { + addOpenAIRefFileID(out, seen, fileID) + } + } + // Recurse into potential containers. Note: we do NOT recurse into 'content' or 'input' + // if they are plain strings (handled by the top-level switch), but they are usually + // nested inside the map branch anyway. + // To be safe, we only recurse into these known container keys. + for _, key := range []string{"ref_file_ids", "file_ids", "attachments", "messages", "input", "content", "files", "items", "data", "source"} { + if nested, ok := x[key]; ok { + // If it's a message content that is a string, we must NOT treat it as an ID. + if key == "content" || key == "input" { + if _, ok := nested.(string); ok { + continue + } + } + appendOpenAIRefFileIDs(out, seen, nested) + } + } + } +} + +func addOpenAIRefFileID(out *[]string, seen map[string]struct{}, fileID string) { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return + } + if _, ok := seen[fileID]; ok { + return + } + seen[fileID] = struct{}{} + *out = append(*out, fileID) +} diff --git a/internal/adapter/openai/files_route_test.go b/internal/adapter/openai/files_route_test.go new file mode 100644 index 00000000..6c8eb0b8 --- /dev/null +++ b/internal/adapter/openai/files_route_test.go @@ -0,0 +1,202 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +type managedFilesAuthStub struct{} + +func (managedFilesAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: true, + DeepSeekToken: "managed-token", + CallerID: "caller:test", + AccountID: "acct-123", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (managedFilesAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: true, + DeepSeekToken: "managed-token", + CallerID: "caller:test", + AccountID: "acct-123", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (managedFilesAuthStub) Release(_ *auth.RequestAuth) {} + +type filesRouteDSStub struct { + lastReq deepseek.UploadFileRequest + upload *deepseek.UploadFileResult + err error +} + +func (m *filesRouteDSStub) CreateSession(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "", nil +} + +func (m *filesRouteDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int) (string, error) { + return "", nil +} + +func (m *filesRouteDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, req deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { + m.lastReq = req + if m.err != nil { + return nil, m.err + } + if m.upload != nil { + return m.upload, nil + } + return &deepseek.UploadFileResult{ID: "file-123", Filename: req.Filename, Bytes: int64(len(req.Data)), Purpose: req.Purpose, Status: "uploaded"}, nil +} + +func (m *filesRouteDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { + return nil, errors.New("not implemented") +} + +func (m *filesRouteDSStub) DeleteSessionForToken(_ context.Context, _ string, _ string) (*deepseek.DeleteSessionResult, error) { + return &deepseek.DeleteSessionResult{Success: true}, nil +} + +func (m *filesRouteDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error { + return nil +} + +func newMultipartUploadRequest(t *testing.T, purpose string, filename string, data []byte) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if purpose != "" { + if err := writer.WriteField("purpose", purpose); err != nil { + t.Fatalf("write purpose failed: %v", err) + } + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("create form file failed: %v", err) + } + if _, err := part.Write(data); err != nil { + t.Fatalf("write file failed: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer failed: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/files", &body) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestFilesRouteUploadSuccess(t *testing.T) { + ds := &filesRouteDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + + req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world")) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if ds.lastReq.Filename != "notes.txt" { + t.Fatalf("expected filename notes.txt, got %q", ds.lastReq.Filename) + } + if ds.lastReq.Purpose != "assistants" { + t.Fatalf("expected purpose assistants, got %q", ds.lastReq.Purpose) + } + if string(ds.lastReq.Data) != "hello world" { + t.Fatalf("unexpected uploaded data: %q", string(ds.lastReq.Data)) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) + } + if out["object"] != "file" { + t.Fatalf("expected file object, got %#v", out) + } + if out["id"] != "file-123" { + t.Fatalf("expected file id file-123, got %#v", out["id"]) + } + if out["filename"] != "notes.txt" { + t.Fatalf("expected filename notes.txt, got %#v", out["filename"]) + } +} + +func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) { + ds := &filesRouteDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + + req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world")) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) + } + if out["account_id"] != "acct-123" { + t.Fatalf("expected account_id acct-123, got %#v", out["account_id"]) + } +} + +func TestFilesRouteRejectsNonMultipart(t *testing.T) { + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} + r := chi.NewRouter() + RegisterRoutes(r, h) + + req := httptest.NewRequest(http.MethodPost, "/v1/files", bytes.NewBufferString(`{"purpose":"assistants"}`)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestFilesRouteRequiresFileField(t *testing.T) { + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} + r := chi.NewRouter() + RegisterRoutes(r, h) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("purpose", "assistants"); err != nil { + t.Fatalf("write field failed: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer failed: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/files", &body) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/adapter/openai/handler_chat.go b/internal/adapter/openai/handler_chat.go index 3e79dc6d..5599eec4 100644 --- a/internal/adapter/openai/handler_chat.go +++ b/internal/adapter/openai/handler_chat.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "time" "ds2api/internal/auth" @@ -43,11 +44,20 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { r = r.WithContext(auth.WithAuth(r.Context(), a)) + r.Body = http.MaxBytesReader(w, r.Body, openAIGeneralMaxSize) var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "too large") { + writeOpenAIError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } writeOpenAIError(w, http.StatusBadRequest, "invalid json") return } + if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil { + writeOpenAIInlineFileError(w, err) + return + } stdReq, err := normalizeOpenAIChatRequest(h.Store, req, requestTraceID(r)) if err != nil { writeOpenAIError(w, http.StatusBadRequest, err.Error()) @@ -127,7 +137,7 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re stripReferenceMarkers := h.compatStripReferenceMarkers() finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers) finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers) - if writeUpstreamEmptyOutputError(w, finalThinking, finalText, result.ContentFilter) { + if writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) { return } respBody := openaifmt.BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText, toolNames) diff --git a/internal/adapter/openai/handler_chat_auto_delete_test.go b/internal/adapter/openai/handler_chat_auto_delete_test.go index d8faa5b8..5a5577a9 100644 --- a/internal/adapter/openai/handler_chat_auto_delete_test.go +++ b/internal/adapter/openai/handler_chat_auto_delete_test.go @@ -27,6 +27,10 @@ func (m *autoDeleteModeDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ return "pow", nil } +func (m *autoDeleteModeDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { + return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil +} + func (m *autoDeleteModeDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { return m.resp, nil } diff --git a/internal/adapter/openai/handler_files.go b/internal/adapter/openai/handler_files.go new file mode 100644 index 00000000..f15ea3ba --- /dev/null +++ b/internal/adapter/openai/handler_files.go @@ -0,0 +1,104 @@ +package openai + +import ( + "io" + "net/http" + "strings" + "time" + + "ds2api/internal/auth" + "ds2api/internal/deepseek" +) + +const openAIUploadMaxMemory = 32 << 20 + +func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { + a, err := h.Auth.Determine(r) + if err != nil { + status := http.StatusUnauthorized + detail := err.Error() + if err == auth.ErrNoAccount { + status = http.StatusTooManyRequests + } + writeOpenAIError(w, status, detail) + return + } + defer h.Auth.Release(a) + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))), "multipart/form-data") { + writeOpenAIError(w, http.StatusBadRequest, "content-type must be multipart/form-data") + return + } + // Enforce a hard cap on the total request body size to prevent OOM + r.Body = http.MaxBytesReader(w, r.Body, openAIUploadMaxSize) + if err := r.ParseMultipartForm(openAIUploadMaxMemory); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "too large") { + writeOpenAIError(w, http.StatusRequestEntityTooLarge, "file size exceeds limit") + return + } + writeOpenAIError(w, http.StatusBadRequest, "invalid multipart form") + return + } + if r.MultipartForm != nil { + defer func() { _ = r.MultipartForm.RemoveAll() }() + } + r = r.WithContext(auth.WithAuth(r.Context(), a)) + file, header, err := r.FormFile("file") + if err != nil { + writeOpenAIError(w, http.StatusBadRequest, "file is required") + return + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(file) + if err != nil { + writeOpenAIError(w, http.StatusBadRequest, "failed to read uploaded file") + return + } + contentType := strings.TrimSpace(header.Header.Get("Content-Type")) + if contentType == "" && len(data) > 0 { + contentType = http.DetectContentType(data) + } + result, err := h.DS.UploadFile(r.Context(), a, deepseek.UploadFileRequest{ + Filename: header.Filename, + ContentType: contentType, + Purpose: strings.TrimSpace(r.FormValue("purpose")), + Data: data, + }, 3) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.") + return + } + if result != nil && result.AccountID == "" { + result.AccountID = a.AccountID + } + writeJSON(w, http.StatusOK, buildOpenAIFileObject(result)) +} + +func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any { + if result == nil { + obj := map[string]any{ + "id": "", + "object": "file", + "bytes": 0, + "created_at": time.Now().Unix(), + "filename": "", + "purpose": "", + "status": "uploaded", + "status_details": nil, + } + return obj + } + obj := map[string]any{ + "id": result.ID, + "object": "file", + "bytes": result.Bytes, + "created_at": time.Now().Unix(), + "filename": result.Filename, + "purpose": result.Purpose, + "status": result.Status, + "status_details": nil, + } + if result.AccountID != "" { + obj["account_id"] = result.AccountID + } + return obj +} diff --git a/internal/adapter/openai/handler_routes.go b/internal/adapter/openai/handler_routes.go index 1f8366a9..5e489530 100644 --- a/internal/adapter/openai/handler_routes.go +++ b/internal/adapter/openai/handler_routes.go @@ -13,6 +13,13 @@ import ( "ds2api/internal/util" ) +const ( + // openAIUploadMaxSize limits total multipart request body size (100 MiB). + openAIUploadMaxSize = 100 << 20 + // openAIGeneralMaxSize limits total JSON request body size (100 MiB). + openAIGeneralMaxSize = 100 << 20 +) + // writeJSON is a package-internal alias kept to avoid mass-renaming across // every call-site in this package. var writeJSON = util.WriteJSON @@ -46,6 +53,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { r.Post("/v1/chat/completions", h.ChatCompletions) r.Post("/v1/responses", h.Responses) r.Get("/v1/responses/{response_id}", h.GetResponseByID) + r.Post("/v1/files", h.UploadFile) r.Post("/v1/embeddings", h.Embeddings) } diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index e595f53c..60924610 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -313,6 +313,25 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp } } +func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) { + h := &Handler{} + resp := makeSSEHTTPResponse( + `data: {"p":"response/thinking_content","v":"Only thinking"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + + h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, nil) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) + } + out := decodeJSONBody(t, rec.Body.String()) + errObj, _ := out["error"].(map[string]any) + if asString(errObj["code"]) != "upstream_empty_output" { + t.Fatalf("expected code=upstream_empty_output, got %#v", out) + } +} + func TestHandleStreamToolCallInterceptsWithoutRawContentLeak(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( diff --git a/internal/adapter/openai/leaked_output_sanitize.go b/internal/adapter/openai/leaked_output_sanitize.go index c139feb1..70f6eeb5 100644 --- a/internal/adapter/openai/leaked_output_sanitize.go +++ b/internal/adapter/openai/leaked_output_sanitize.go @@ -2,13 +2,21 @@ package openai import ( "regexp" + "strings" ) var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```") var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`) var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`) -// leakedMetaMarkerPattern matches DeepSeek special tokens in BOTH forms: +var leakedThinkTagPattern = regexp.MustCompile(`(?is)`) + +// leakedBOSMarkerPattern matches DeepSeek BOS markers in BOTH forms: +// - ASCII underscore: <|begin_of_sentence|> +// - U+2581 variant: <|begin▁of▁sentence|> +var leakedBOSMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*begin[_▁]of[_▁]sentence\s*[|\|]>`) + +// leakedMetaMarkerPattern matches the remaining DeepSeek special tokens in BOTH forms: // - ASCII underscore: <|end_of_sentence|>, <|end_of_toolresults|>, <|end_of_instructions|> // - U+2581 variant: <|end▁of▁sentence|>, <|end▁of▁toolresults|>, <|end▁of▁instructions|> var leakedMetaMarkerPattern = regexp.MustCompile(`(?i)<[|\|]\s*(?:assistant|tool|end[_▁]of[_▁]sentence|end[_▁]of[_▁]thinking|end[_▁]of[_▁]toolresults|end[_▁]of[_▁]instructions)\s*[|\|]>`) @@ -35,11 +43,48 @@ func sanitizeLeakedOutput(text string) string { out := emptyJSONFencePattern.ReplaceAllString(text, "") out = leakedToolCallArrayPattern.ReplaceAllString(out, "") out = leakedToolResultBlobPattern.ReplaceAllString(out, "") + out = stripDanglingThinkSuffix(out) + out = leakedThinkTagPattern.ReplaceAllString(out, "") + out = leakedBOSMarkerPattern.ReplaceAllString(out, "") out = leakedMetaMarkerPattern.ReplaceAllString(out, "") out = sanitizeLeakedAgentXMLBlocks(out) return out } +func stripDanglingThinkSuffix(text string) string { + matches := leakedThinkTagPattern.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + depth := 0 + lastOpen := -1 + for _, loc := range matches { + tag := strings.ToLower(text[loc[0]:loc[1]]) + compact := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(tag), " ", ""), "\t", "") + if strings.HasPrefix(compact, " 0 { + depth-- + if depth == 0 { + lastOpen = -1 + } + } + continue + } + if depth == 0 { + lastOpen = loc[0] + } + depth++ + } + if depth == 0 || lastOpen < 0 { + return text + } + prefix := text[:lastOpen] + if strings.TrimSpace(prefix) == "" { + return "" + } + return prefix +} + func sanitizeLeakedAgentXMLBlocks(text string) string { out := text for _, pattern := range leakedAgentXMLBlockPatterns { diff --git a/internal/adapter/openai/leaked_output_sanitize_test.go b/internal/adapter/openai/leaked_output_sanitize_test.go index 558cc48b..e72bf022 100644 --- a/internal/adapter/openai/leaked_output_sanitize_test.go +++ b/internal/adapter/openai/leaked_output_sanitize_test.go @@ -26,6 +26,22 @@ func TestSanitizeLeakedOutputRemovesStandaloneMetaMarkers(t *testing.T) { } } +func TestSanitizeLeakedOutputRemovesThinkAndBosMarkers(t *testing.T) { + raw := "ABC<|begin▁of▁sentence|>D<| begin_of_sentence |>E<|begin_of_sentence|>F" + got := sanitizeLeakedOutput(raw) + if got != "ABCDEF" { + t.Fatalf("unexpected sanitize result for think/BOS markers: %q", got) + } +} + +func TestSanitizeLeakedOutputRemovesDanglingThinkBlock(t *testing.T) { + raw := "Answer prefixinternal reasoning that never closes" + got := sanitizeLeakedOutput(raw) + if got != "Answer prefix" { + t.Fatalf("unexpected sanitize result for dangling think block: %q", got) + } +} + func TestSanitizeLeakedOutputRemovesAgentXMLLeaks(t *testing.T) { raw := "Done.Some final answer" got := sanitizeLeakedOutput(raw) diff --git a/internal/adapter/openai/prompt_build.go b/internal/adapter/openai/prompt_build.go index d6823b23..2e1d8915 100644 --- a/internal/adapter/openai/prompt_build.go +++ b/internal/adapter/openai/prompt_build.go @@ -5,22 +5,22 @@ import ( "ds2api/internal/util" ) -func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string) (string, []string) { - return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy()) +func buildOpenAIFinalPrompt(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { + return buildOpenAIFinalPromptWithPolicy(messagesRaw, toolsRaw, traceID, util.DefaultToolChoicePolicy(), thinkingEnabled) } -func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy) (string, []string) { +func buildOpenAIFinalPromptWithPolicy(messagesRaw []any, toolsRaw any, traceID string, toolPolicy util.ToolChoicePolicy, thinkingEnabled bool) (string, []string) { messages := normalizeOpenAIMessagesForPrompt(messagesRaw, traceID) toolNames := []string{} if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 { messages, toolNames = injectToolPrompt(messages, tools, toolPolicy) } - return deepseek.MessagesPrepare(messages), toolNames + return deepseek.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames } // BuildPromptForAdapter exposes the OpenAI-compatible prompt building flow so // other protocol adapters (for example Gemini) can reuse the same tool/history // normalization logic and remain behavior-compatible with chat/completions. -func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string) (string, []string) { - return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID) +func BuildPromptForAdapter(messagesRaw []any, toolsRaw any, traceID string, thinkingEnabled bool) (string, []string) { + return buildOpenAIFinalPrompt(messagesRaw, toolsRaw, traceID, thinkingEnabled) } diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/adapter/openai/prompt_build_test.go index 390cbd48..724fef88 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/adapter/openai/prompt_build_test.go @@ -40,7 +40,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes }, } - finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "") + finalPrompt, toolNames := buildOpenAIFinalPrompt(messages, tools, "", false) if len(toolNames) != 1 || toolNames[0] != "get_weather" { t.Fatalf("unexpected tool names: %#v", toolNames) } @@ -73,7 +73,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * }, } - finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "") + finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) if !strings.Contains(finalPrompt, "Remember: Output ONLY the ... XML block when calling tools.") { t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt) } diff --git a/internal/adapter/openai/responses_embeddings_test.go b/internal/adapter/openai/responses_embeddings_test.go index 2907bd60..a75cc3f5 100644 --- a/internal/adapter/openai/responses_embeddings_test.go +++ b/internal/adapter/openai/responses_embeddings_test.go @@ -156,6 +156,33 @@ func TestNormalizeResponsesInputAsMessagesFunctionCallItemPreservesConcatenatedA } } +func TestCollectOpenAIRefFileIDs(t *testing.T) { + got := collectOpenAIRefFileIDs(map[string]any{ + "ref_file_ids": []any{"file-top", "file-dup"}, + "attachments": []any{ + map[string]any{"file_id": "file-attachment"}, + }, + "input": []any{ + map[string]any{ + "type": "message", + "content": []any{ + map[string]any{"type": "input_file", "file_id": "file-input"}, + map[string]any{"type": "input_file", "id": "file-dup"}, + }, + }, + }, + }) + want := []string{"file-top", "file-dup", "file-attachment", "file-input"} + if len(got) != len(want) { + t.Fatalf("expected %d file ids, got %#v", len(want), got) + } + for i, id := range want { + if got[i] != id { + t.Fatalf("unexpected file ids at %d: got=%#v want=%#v", i, got, want) + } + } +} + func TestExtractEmbeddingInputs(t *testing.T) { got := extractEmbeddingInputs([]any{"a", "b"}) if len(got) != 2 || got[0] != "a" || got[1] != "b" { diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index e9947ff8..6494157c 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -65,11 +65,20 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) { return } + r.Body = http.MaxBytesReader(w, r.Body, openAIGeneralMaxSize) var req map[string]any if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "too large") { + writeOpenAIError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } writeOpenAIError(w, http.StatusBadRequest, "invalid json") return } + if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil { + writeOpenAIInlineFileError(w, err) + return + } traceID := requestTraceID(r) stdReq, err := normalizeOpenAIResponsesRequest(h.Store, req, traceID) if err != nil { @@ -117,7 +126,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res stripReferenceMarkers := h.compatStripReferenceMarkers() sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers) sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers) - if writeUpstreamEmptyOutputError(w, sanitizedThinking, sanitizedText, result.ContentFilter) { + if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) { return } textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index ec4bdb1d..45863dc1 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -99,6 +99,30 @@ func newResponsesStreamRuntime( } } +func (s *responsesStreamRuntime) failResponse(message, code string) { + s.failed = true + failedResp := map[string]any{ + "id": s.responseID, + "type": "response", + "object": "response", + "model": s.model, + "status": "failed", + "output": []any{}, + "output_text": "", + "error": map[string]any{ + "message": message, + "type": "invalid_request_error", + "code": code, + "param": nil, + }, + } + if s.persistResponse != nil { + s.persistResponse(failedResp) + } + s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, code)) + s.sendDone() +} + func (s *responsesStreamRuntime) finalize() { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) @@ -121,28 +145,16 @@ func (s *responsesStreamRuntime) finalize() { s.closeMessageItem() if s.toolChoice.IsRequired() && len(detected) == 0 { - s.failed = true - message := "tool_choice requires at least one valid tool call." - failedResp := map[string]any{ - "id": s.responseID, - "type": "response", - "object": "response", - "model": s.model, - "status": "failed", - "output": []any{}, - "output_text": "", - "error": map[string]any{ - "message": message, - "type": "invalid_request_error", - "code": "tool_choice_violation", - "param": nil, - }, - } - if s.persistResponse != nil { - s.persistResponse(failedResp) + s.failResponse("tool_choice requires at least one valid tool call.", "tool_choice_violation") + return + } + if len(detected) == 0 && strings.TrimSpace(finalText) == "" { + code := "upstream_empty_output" + message := "Upstream model returned empty output." + if finalThinking != "" { + message = "Upstream model returned reasoning without visible output." } - s.sendEvent("response.failed", openaifmt.BuildResponsesFailedPayload(s.responseID, s.model, message, "tool_choice_violation")) - s.sendDone() + s.failResponse(message, code) return } s.closeIncompleteFunctionItems() diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index 138e9d00..1014d782 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -518,6 +518,44 @@ func TestHandleResponsesStreamRequiredMalformedToolPayloadFails(t *testing.T) { } } +func TestHandleResponsesStreamFailsWhenUpstreamHasOnlyThinking(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + rec := httptest.NewRecorder() + + sseLine := func(path, value string) string { + b, _ := json.Marshal(map[string]any{ + "p": path, + "v": value, + }) + return "data: " + string(b) + "\n" + } + + streamBody := sseLine("response/thinking_content", "Only thinking") + "data: [DONE]\n" + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(streamBody)), + } + + h.handleResponsesStream(rec, req, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "") + + body := rec.Body.String() + if !strings.Contains(body, "event: response.failed") { + t.Fatalf("expected response.failed event, body=%s", body) + } + if strings.Contains(body, "event: response.completed") { + t.Fatalf("did not expect response.completed, body=%s", body) + } + payload, ok := extractSSEEventPayload(body, "response.failed") + if !ok { + t.Fatalf("expected response.failed payload, body=%s", body) + } + errObj, _ := payload["error"].(map[string]any) + if asString(errObj["code"]) != "upstream_empty_output" { + t.Fatalf("expected code=upstream_empty_output, got %#v", payload) + } +} + func TestHandleResponsesStreamAllowsUnknownToolName(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) @@ -671,6 +709,28 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi } } +func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) { + h := &Handler{} + rec := httptest.NewRecorder() + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `data: {"p":"response/thinking_content","v":"Only thinking"}` + "\n" + + `data: [DONE]` + "\n", + )), + } + + h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, nil, util.DefaultToolChoicePolicy(), "") + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String()) + } + out := decodeJSONBody(t, rec.Body.String()) + errObj, _ := out["error"].(map[string]any) + if asString(errObj["code"]) != "upstream_empty_output" { + t.Fatalf("expected code=upstream_empty_output, got %#v", out) + } +} + func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) { scanner := bufio.NewScanner(strings.NewReader(body)) matched := false diff --git a/internal/adapter/openai/standard_request.go b/internal/adapter/openai/standard_request.go index e9904f7a..3cdf6409 100644 --- a/internal/adapter/openai/standard_request.go +++ b/internal/adapter/openai/standard_request.go @@ -24,9 +24,10 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID responseModel = resolvedModel } toolPolicy := util.DefaultToolChoicePolicy() - finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy) + finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) passThrough := collectOpenAIChatPassThrough(req) + refFileIDs := collectOpenAIRefFileIDs(req) return util.StandardRequest{ Surface: "openai_chat", @@ -40,6 +41,7 @@ func normalizeOpenAIChatRequest(store ConfigReader, req map[string]any, traceID Stream: util.ToBool(req["stream"]), Thinking: thinkingEnabled, Search: searchEnabled, + RefFileIDs: refFileIDs, PassThrough: passThrough, }, nil } @@ -74,12 +76,13 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra if err != nil { return util.StandardRequest{}, err } - finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy) + finalPrompt, toolNames := buildOpenAIFinalPromptWithPolicy(messagesRaw, req["tools"], traceID, toolPolicy, thinkingEnabled) toolNames = ensureToolDetectionEnabled(toolNames, req["tools"]) if !toolPolicy.IsNone() { toolPolicy.Allowed = namesToSet(toolNames) } passThrough := collectOpenAIChatPassThrough(req) + refFileIDs := collectOpenAIRefFileIDs(req) return util.StandardRequest{ Surface: "openai_responses", @@ -93,6 +96,7 @@ func normalizeOpenAIResponsesRequest(store ConfigReader, req map[string]any, tra Stream: util.ToBool(req["stream"]), Thinking: thinkingEnabled, Search: searchEnabled, + RefFileIDs: refFileIDs, PassThrough: passThrough, }, nil } diff --git a/internal/adapter/openai/standard_request_test.go b/internal/adapter/openai/standard_request_test.go index 45a3976b..dace3af2 100644 --- a/internal/adapter/openai/standard_request_test.go +++ b/internal/adapter/openai/standard_request_test.go @@ -41,6 +41,36 @@ func TestNormalizeOpenAIChatRequest(t *testing.T) { } } +func TestNormalizeOpenAIChatRequestCollectsRefFileIDs(t *testing.T) { + store := newEmptyStoreForNormalizeTest(t) + req := map[string]any{ + "model": "gpt-5-codex", + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "input_text", "text": "hello"}, + map[string]any{"type": "input_file", "file_id": "file-msg"}, + }, + }, + }, + "attachments": []any{ + map[string]any{"file_id": "file-attachment"}, + }, + "ref_file_ids": []any{"file-top", "file-attachment"}, + } + n, err := normalizeOpenAIChatRequest(store, req, "") + if err != nil { + t.Fatalf("normalize failed: %v", err) + } + if len(n.RefFileIDs) != 3 { + t.Fatalf("expected 3 distinct file ids, got %#v", n.RefFileIDs) + } + if n.RefFileIDs[0] != "file-top" || n.RefFileIDs[1] != "file-attachment" || n.RefFileIDs[2] != "file-msg" { + t.Fatalf("unexpected file ids: %#v", n.RefFileIDs) + } +} + func TestNormalizeOpenAIResponsesRequestInput(t *testing.T) { store := newEmptyStoreForNormalizeTest(t) req := map[string]any{ diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index 34de14fe..0734c4d6 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -50,6 +50,10 @@ func (m streamStatusDSStub) GetPow(_ context.Context, _ *auth.RequestAuth, _ int return "pow", nil } +func (m streamStatusDSStub) UploadFile(_ context.Context, _ *auth.RequestAuth, _ deepseek.UploadFileRequest, _ int) (*deepseek.UploadFileResult, error) { + return &deepseek.UploadFileResult{ID: "file-id", Filename: "file.txt", Bytes: 1, Status: "uploaded"}, nil +} + func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAuth, _ map[string]any, _ string, _ int) (*http.Response, error) { return m.resp, nil } @@ -239,6 +243,49 @@ func TestChatCompletionsStreamContentFilterStopsNormallyWithoutLeak(t *testing.T } } +func TestChatCompletionsStreamEmitsFailureFrameWhenUpstreamOutputEmpty(t *testing.T) { + statuses := make([]int, 0, 1) + h := &Handler{ + Store: mockOpenAIConfig{wideInput: true}, + Auth: streamStatusAuthStub{}, + DS: streamStatusDSStub{resp: makeOpenAISSEHTTPResponse("data: [DONE]")}, + } + r := chi.NewRouter() + r.Use(captureStatusMiddleware(&statuses)) + RegisterRoutes(r, h) + + reqBody := `{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}],"stream":true}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer direct-token") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if len(statuses) != 1 || statuses[0] != http.StatusOK { + t.Fatalf("expected captured status 200, got %#v", statuses) + } + + frames, done := parseSSEDataFrames(t, rec.Body.String()) + if !done { + t.Fatalf("expected [DONE], body=%s", rec.Body.String()) + } + if len(frames) != 1 { + t.Fatalf("expected one failure frame, got %#v body=%s", frames, rec.Body.String()) + } + last := frames[0] + statusCode, ok := last["status_code"].(float64) + if !ok || int(statusCode) != http.StatusTooManyRequests { + t.Fatalf("expected status_code=429, got %#v body=%s", last["status_code"], rec.Body.String()) + } + errObj, _ := last["error"].(map[string]any) + if asString(errObj["code"]) != "upstream_empty_output" { + t.Fatalf("expected code=upstream_empty_output, got %#v", last) + } +} + func TestResponsesStreamUsageIgnoresBatchAccumulatedTokenUsage(t *testing.T) { statuses := make([]int, 0, 1) h := &Handler{ diff --git a/internal/adapter/openai/upstream_empty.go b/internal/adapter/openai/upstream_empty.go index 071ffced..9c21adc8 100644 --- a/internal/adapter/openai/upstream_empty.go +++ b/internal/adapter/openai/upstream_empty.go @@ -2,8 +2,8 @@ package openai import "net/http" -func writeUpstreamEmptyOutputError(w http.ResponseWriter, thinking, text string, contentFilter bool) bool { - if thinking != "" || text != "" { +func writeUpstreamEmptyOutputError(w http.ResponseWriter, text string, contentFilter bool) bool { + if text != "" { return false } if contentFilter { diff --git a/internal/adapter/openai/vercel_stream.go b/internal/adapter/openai/vercel_stream.go index 2ef8d364..3e56b3ea 100644 --- a/internal/adapter/openai/vercel_stream.go +++ b/internal/adapter/openai/vercel_stream.go @@ -52,6 +52,10 @@ func (h *Handler) handleVercelStreamPrepare(w http.ResponseWriter, r *http.Reque writeOpenAIError(w, http.StatusBadRequest, "invalid json") return } + if err := h.preprocessInlineFileInputs(r.Context(), a, req); err != nil { + writeOpenAIInlineFileError(w, err) + return + } if !util.ToBool(req["stream"]) { writeOpenAIError(w, http.StatusBadRequest, "stream must be true") return diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index 86a6cefe..23beb78f 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -91,17 +91,25 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte } func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) { + return c.GetPowForTarget(ctx, a, DeepSeekCompletionTargetPath, maxAttempts) +} + +func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targetPath string, maxAttempts int) (string, error) { if maxAttempts <= 0 { maxAttempts = c.maxRetries } + targetPath = strings.TrimSpace(targetPath) + if targetPath == "" { + targetPath = DeepSeekCompletionTargetPath + } clients := c.requestClientsForAuth(ctx, a) attempts := 0 refreshed := false for attempts < maxAttempts { headers := c.authHeaders(a.DeepSeekToken) - resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"}) + resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath}) if err != nil { - config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID) + config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath) attempts++ continue } @@ -117,7 +125,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in } return BuildPowHeader(challenge, answer) } - config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID) + config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID, "target_path", targetPath) if a.UseConfigToken { if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { if c.Auth.RefreshToken(ctx, a) { diff --git a/internal/deepseek/client_completion.go b/internal/deepseek/client_completion.go index cab2cabd..c27a88f2 100644 --- a/internal/deepseek/client_completion.go +++ b/internal/deepseek/client_completion.go @@ -51,6 +51,7 @@ func (c *Client) streamPost(ctx context.Context, doer trans.Doer, url string, he if err != nil { return nil, err } + headers = c.jsonHeaders(headers) clients := c.requestClientsFromContext(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if err != nil { diff --git a/internal/deepseek/client_file_status.go b/internal/deepseek/client_file_status.go new file mode 100644 index 00000000..ba50ab8d --- /dev/null +++ b/internal/deepseek/client_file_status.go @@ -0,0 +1,188 @@ +package deepseek + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "ds2api/internal/auth" + "ds2api/internal/config" +) + +const ( + fileReadyPollAttempts = 60 + fileReadyPollInterval = time.Second + fileReadyPollTimeout = 65 * time.Second +) + +var fileReadySleep = time.Sleep + +func (c *Client) waitForUploadedFile(ctx context.Context, a *auth.RequestAuth, result *UploadFileResult) error { + if result == nil || strings.TrimSpace(result.ID) == "" { + return nil + } + if isReadyUploadFileStatus(result.Status) { + return nil + } + + pollCtx, cancel := context.WithTimeout(ctx, fileReadyPollTimeout) + defer cancel() + + var lastErr error + for attempt := 0; attempt < fileReadyPollAttempts; attempt++ { + if err := pollCtx.Err(); err != nil { + if lastErr != nil { + return fmt.Errorf("waiting for file %s to become ready: %w", result.ID, lastErr) + } + return fmt.Errorf("waiting for file %s to become ready: %w", result.ID, err) + } + + fetched, err := c.fetchUploadedFile(pollCtx, a, result.ID) + if err == nil && fetched != nil { + mergeUploadFileResults(result, fetched) + if isReadyUploadFileStatus(result.Status) { + return nil + } + lastErr = fmt.Errorf("status=%s", strings.TrimSpace(result.Status)) + } else if err != nil { + lastErr = err + config.Logger.Debug("[upload_file] waiting for file readiness", "file_id", result.ID, "attempt", attempt+1, "error", err) + } + + if attempt < fileReadyPollAttempts-1 { + fileReadySleep(fileReadyPollInterval) + } + } + + if lastErr == nil { + lastErr = fmt.Errorf("status=%s", strings.TrimSpace(result.Status)) + } + return fmt.Errorf("file %s did not become ready: %w", result.ID, lastErr) +} + +func (c *Client) fetchUploadedFile(ctx context.Context, a *auth.RequestAuth, fileID string) (*UploadFileResult, error) { + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return nil, errors.New("file id is required") + } + clients := c.requestClientsForAuth(ctx, a) + reqURL := DeepSeekFetchFilesURL + "?file_ids=" + url.QueryEscape(fileID) + headers := c.authHeaders(a.DeepSeekToken) + + resp, status, err := c.getJSONWithStatus(ctx, clients.regular, reqURL, headers) + if err != nil { + return nil, err + } + + code, bizCode, msg, bizMsg := extractResponseStatus(resp) + if status != http.StatusOK || code != 0 || bizCode != 0 { + if strings.TrimSpace(bizMsg) != "" { + msg = bizMsg + } + if msg == "" { + msg = http.StatusText(status) + } + return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg) + } + + result := extractFetchedUploadFileResult(resp, fileID) + if result == nil || strings.TrimSpace(result.ID) == "" { + return nil, errors.New("fetch files succeeded without matching file data") + } + result.Raw = resp + return result, nil +} + +func extractFetchedUploadFileResult(resp map[string]any, targetID string) *UploadFileResult { + targetID = strings.TrimSpace(targetID) + if resp == nil || targetID == "" { + return nil + } + + var walk func(any) *UploadFileResult + walk = func(v any) *UploadFileResult { + switch x := v.(type) { + case map[string]any: + if result := buildUploadFileResultFromMap(x, targetID); result != nil { + return result + } + for _, nested := range x { + if result := walk(nested); result != nil { + return result + } + } + case []any: + for _, item := range x { + if result := walk(item); result != nil { + return result + } + } + } + return nil + } + + if result := walk(resp); result != nil { + return result + } + return nil +} + +func buildUploadFileResultFromMap(m map[string]any, targetID string) *UploadFileResult { + fileID := strings.TrimSpace(firstNonEmptyString(m, "id", "file_id")) + if fileID == "" || !strings.EqualFold(fileID, targetID) { + return nil + } + result := &UploadFileResult{ + ID: fileID, + Filename: firstNonEmptyString(m, "name", "filename", "file_name"), + Status: firstNonEmptyString(m, "status", "file_status"), + Purpose: firstNonEmptyString(m, "purpose"), + IsImage: firstBool(m, "is_image", "isImage"), + Bytes: firstPositiveInt64(m, "bytes", "size", "file_size"), + } + if result.Status == "" { + result.Status = "uploaded" + } + return result +} + +func mergeUploadFileResults(dst, src *UploadFileResult) { + if dst == nil || src == nil { + return + } + if strings.TrimSpace(src.ID) != "" { + dst.ID = strings.TrimSpace(src.ID) + } + if strings.TrimSpace(src.Filename) != "" { + dst.Filename = strings.TrimSpace(src.Filename) + } + if src.Bytes > 0 { + dst.Bytes = src.Bytes + } + if strings.TrimSpace(src.Status) != "" { + dst.Status = strings.TrimSpace(src.Status) + } + if strings.TrimSpace(src.Purpose) != "" { + dst.Purpose = strings.TrimSpace(src.Purpose) + } + dst.IsImage = src.IsImage + if len(src.Raw) > 0 { + dst.Raw = src.Raw + } + if src.RawHeaders != nil { + dst.RawHeaders = src.RawHeaders.Clone() + } +} + +func isReadyUploadFileStatus(status string) bool { + switch strings.ToLower(strings.TrimSpace(status)) { + case "processed", "ready", "done", "available", "success", "completed", "finished": + return true + default: + return false + } +} diff --git a/internal/deepseek/client_http_helpers.go b/internal/deepseek/client_http_helpers.go index 10b5006b..14cfbdd5 100644 --- a/internal/deepseek/client_http_helpers.go +++ b/internal/deepseek/client_http_helpers.go @@ -35,6 +35,12 @@ func preview(b []byte) string { return s } +func (c *Client) jsonHeaders(headers map[string]string) map[string]string { + out := cloneStringMap(headers) + out["Content-Type"] = "application/json" + return out +} + func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error { scanner := bufio.NewScanner(resp.Body) buf := make([]byte, 0, 64*1024) diff --git a/internal/deepseek/client_http_json.go b/internal/deepseek/client_http_json.go index 9f545ef3..88eebaeb 100644 --- a/internal/deepseek/client_http_json.go +++ b/internal/deepseek/client_http_json.go @@ -27,6 +27,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallba if err != nil { return nil, 0, err } + headers = c.jsonHeaders(headers) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if err != nil { return nil, 0, err diff --git a/internal/deepseek/client_upload.go b/internal/deepseek/client_upload.go new file mode 100644 index 00000000..c494b7ba --- /dev/null +++ b/internal/deepseek/client_upload.go @@ -0,0 +1,282 @@ +package deepseek + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strconv" + "strings" + + "ds2api/internal/auth" + "ds2api/internal/config" + trans "ds2api/internal/deepseek/transport" +) + +type UploadFileRequest struct { + Filename string + ContentType string + Purpose string + Data []byte +} + +type UploadFileResult struct { + ID string + Filename string + Bytes int64 + Status string + Purpose string + AccountID string + IsImage bool + Raw map[string]any + RawHeaders http.Header +} + +func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req UploadFileRequest, maxAttempts int) (*UploadFileResult, error) { + if maxAttempts <= 0 { + maxAttempts = c.maxRetries + } + if len(req.Data) == 0 { + return nil, errors.New("file is required") + } + filename := strings.TrimSpace(req.Filename) + if filename == "" { + filename = "upload.bin" + } + contentType := strings.TrimSpace(req.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + purpose := strings.TrimSpace(req.Purpose) + body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data) + if err != nil { + return nil, err + } + capturePayload := map[string]any{ + "filename": filename, + "content_type": contentType, + "purpose": purpose, + "bytes": len(req.Data), + } + captureSession := c.capture.Start("deepseek_upload_file", DeepSeekUploadFileURL, a.AccountID, capturePayload) + attempts := 0 + refreshed := false + powHeader := "" + for attempts < maxAttempts { + clients := c.requestClientsForAuth(ctx, a) + if strings.TrimSpace(powHeader) == "" { + powHeader, err = c.GetPowForTarget(ctx, a, DeepSeekUploadTargetPath, maxAttempts) + if err != nil { + return nil, err + } + clients = c.requestClientsForAuth(ctx, a) + } + headers := c.authHeaders(a.DeepSeekToken) + headers["Content-Type"] = contentTypeHeader + headers["x-ds-pow-response"] = powHeader + headers["x-file-size"] = strconv.Itoa(len(req.Data)) + headers["x-thinking-enabled"] = "1" + resp, err := c.doUpload(ctx, clients.regular, clients.fallback, DeepSeekUploadFileURL, headers, body) + if err != nil { + config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename) + powHeader = "" + attempts++ + continue + } + if captureSession != nil { + resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode) + } + payloadBytes, readErr := readResponseBody(resp) + _ = resp.Body.Close() + if readErr != nil { + powHeader = "" + attempts++ + continue + } + parsed := map[string]any{} + if len(payloadBytes) > 0 { + if err := json.Unmarshal(payloadBytes, &parsed); err != nil { + config.Logger.Warn("[upload_file] json parse failed", "status", resp.StatusCode, "preview", preview(payloadBytes)) + } + } + code, bizCode, msg, bizMsg := extractResponseStatus(parsed) + if resp.StatusCode == http.StatusOK && code == 0 && bizCode == 0 { + result := extractUploadFileResult(parsed) + result.Raw = parsed + result.RawHeaders = resp.Header.Clone() + if result.Filename == "" { + result.Filename = filename + } + if result.Bytes == 0 { + result.Bytes = int64(len(req.Data)) + } + if result.Purpose == "" { + result.Purpose = purpose + } + if result.AccountID == "" { + result.AccountID = a.AccountID + } + if result.ID == "" { + return nil, errors.New("upload file succeeded without file id") + } + if err := c.waitForUploadedFile(ctx, a, result); err != nil { + return nil, err + } + return result, nil + } + config.Logger.Warn("[upload_file] failed", "status", resp.StatusCode, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID, "filename", filename) + powHeader = "" + if a.UseConfigToken { + if !refreshed && shouldAttemptRefresh(resp.StatusCode, code, bizCode, msg, bizMsg) { + if c.Auth.RefreshToken(ctx, a) { + refreshed = true + attempts++ + continue + } + } + if c.Auth.SwitchAccount(ctx, a) { + refreshed = false + attempts++ + continue + } + } + attempts++ + } + return nil, errors.New("upload file failed") +} + +func buildUploadMultipartBody(filename, contentType string, data []byte) ([]byte, string, error) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, escapeMultipartFilename(filename))) + partHeader.Set("Content-Type", contentType) + part, err := writer.CreatePart(partHeader) + if err != nil { + return nil, "", err + } + if _, err := part.Write(data); err != nil { + return nil, "", err + } + if err := writer.Close(); err != nil { + return nil, "", err + } + return buf.Bytes(), writer.FormDataContentType(), nil +} + +func escapeMultipartFilename(filename string) string { + filename = filepath.Base(strings.TrimSpace(filename)) + filename = strings.ReplaceAll(filename, `\`, "_") + filename = strings.ReplaceAll(filename, `"`, "_") + if filename == "." || filename == "" { + return "upload.bin" + } + return filename +} + +func (c *Client) doUpload(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := doer.Do(req) + if err == nil { + return resp, nil + } + config.Logger.Warn("[deepseek] fingerprint upload request failed, fallback to std transport", "url", url, "error", err) + req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if reqErr != nil { + return nil, reqErr + } + for k, v := range headers { + req2.Header.Set(k, v) + } + return fallback.Do(req2) +} + +func extractUploadFileResult(resp map[string]any) *UploadFileResult { + result := &UploadFileResult{Status: "uploaded"} + data, _ := resp["data"].(map[string]any) + bizData, _ := data["biz_data"].(map[string]any) + searchMaps := []map[string]any{resp, data, bizData} + for _, parent := range []map[string]any{resp, data, bizData} { + if parent == nil { + continue + } + for _, key := range []string{"file", "biz_data", "data"} { + if nested, ok := parent[key].(map[string]any); ok { + searchMaps = append(searchMaps, nested) + } + } + } + for _, m := range searchMaps { + if m == nil { + continue + } + if result.ID == "" { + result.ID = firstNonEmptyString(m, "id", "file_id") + } + if result.Filename == "" { + result.Filename = firstNonEmptyString(m, "name", "filename", "file_name") + } + if result.Status == "uploaded" { + if status := firstNonEmptyString(m, "status", "file_status"); status != "" { + result.Status = status + } + } + if !result.IsImage { + result.IsImage = firstBool(m, "is_image", "isImage") + } + if result.Purpose == "" { + result.Purpose = firstNonEmptyString(m, "purpose") + } + if result.AccountID == "" { + result.AccountID = firstNonEmptyString(m, "account_id", "accountId", "owner_account_id", "ownerAccountId") + } + if result.Bytes == 0 { + result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size") + } + } + return result +} + +func firstBool(m map[string]any, keys ...string) bool { + for _, key := range keys { + switch v := m[key].(type) { + case bool: + return v + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "y": + return true + } + } + } + return false +} + +func firstNonEmptyString(m map[string]any, keys ...string) string { + for _, key := range keys { + if v, _ := m[key].(string); strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func firstPositiveInt64(m map[string]any, keys ...string) int64 { + for _, key := range keys { + if v := toInt64(m[key], 0); v > 0 { + return v + } + } + return 0 +} diff --git a/internal/deepseek/client_upload_test.go b/internal/deepseek/client_upload_test.go new file mode 100644 index 00000000..7a41073a --- /dev/null +++ b/internal/deepseek/client_upload_test.go @@ -0,0 +1,216 @@ +package deepseek + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "ds2api/internal/auth" + powpkg "ds2api/pow" +) + +func TestBuildUploadMultipartBodyOmitsPurposeAndIncludesFilePart(t *testing.T) { + body, contentType, err := buildUploadMultipartBody(`../demo.txt`, "text/plain", []byte("hello")) + if err != nil { + t.Fatalf("buildUploadMultipartBody error: %v", err) + } + if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") { + t.Fatalf("unexpected content type: %q", contentType) + } + payload := string(body) + if strings.Contains(payload, `name="purpose"`) || strings.Contains(payload, "assistants") { + t.Fatalf("expected purpose to be omitted from payload: %q", payload) + } + if !strings.Contains(payload, `name="file"; filename="demo.txt"`) { + t.Fatalf("expected sanitized filename in payload: %q", payload) + } + if !strings.Contains(payload, "Content-Type: text/plain") { + t.Fatalf("expected file content type in payload: %q", payload) + } + if !strings.Contains(payload, "hello") { + t.Fatalf("expected file content in payload: %q", payload) + } +} + +func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) { + got := extractUploadFileResult(map[string]any{ + "data": map[string]any{ + "biz_data": map[string]any{ + "file": map[string]any{ + "file_id": "file_123", + "file_name": "report.pdf", + "file_size": 99, + "status": "processed", + "purpose": "assistants", + "is_image": true, + }, + }, + }, + }) + if got.ID != "file_123" { + t.Fatalf("expected id file_123, got %#v", got) + } + if got.Filename != "report.pdf" { + t.Fatalf("expected filename report.pdf, got %#v", got) + } + if got.Bytes != 99 { + t.Fatalf("expected bytes 99, got %#v", got) + } + if got.Status != "processed" { + t.Fatalf("expected status processed, got %#v", got) + } + if got.Purpose != "assistants" { + t.Fatalf("expected purpose assistants, got %#v", got) + } + if !got.IsImage { + t.Fatalf("expected image flag true, got %#v", got) + } +} + +func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) { + challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42")) + powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + DeepSeekUploadTargetPath + `"}}}}` + uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":false}}}}` + var seenPow string + var seenTargetPath string + var seenContentType string + var seenFileSize string + var seenBody string + call := 0 + client := &Client{ + regular: doerFunc(func(req *http.Request) (*http.Response, error) { + call++ + bodyBytes, _ := io.ReadAll(req.Body) + switch call { + case 1: + seenTargetPath = string(bodyBytes) + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil + case 2: + seenPow = req.Header.Get("x-ds-pow-response") + seenContentType = req.Header.Get("Content-Type") + seenFileSize = req.Header.Get("x-file-size") + seenBody = string(bodyBytes) + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil + default: + t.Fatalf("unexpected request count %d", call) + return nil, nil + } + }), + fallback: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return nil, nil + })}, + maxRetries: 1, + } + result, err := client.UploadFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token", TriedAccounts: map[string]bool{}}, UploadFileRequest{ + Filename: "demo.txt", + ContentType: "text/plain", + Purpose: "assistants", + Data: []byte("hello"), + }, 1) + if err != nil { + t.Fatalf("UploadFile error: %v", err) + } + if result.ID != "file_789" { + t.Fatalf("expected uploaded file id file_789, got %#v", result) + } + if !strings.Contains(seenTargetPath, `"target_path":"`+DeepSeekUploadTargetPath+`"`) { + t.Fatalf("expected upload target_path in pow request, got %q", seenTargetPath) + } + if strings.TrimSpace(seenPow) == "" { + t.Fatal("expected x-ds-pow-response header") + } + rawPow, err := base64.StdEncoding.DecodeString(seenPow) + if err != nil { + t.Fatalf("decode pow header failed: %v", err) + } + var powHeader map[string]any + if err := json.Unmarshal(rawPow, &powHeader); err != nil { + t.Fatalf("unmarshal pow header failed: %v", err) + } + if powHeader["target_path"] != DeepSeekUploadTargetPath { + t.Fatalf("expected pow target_path %q, got %#v", DeepSeekUploadTargetPath, powHeader["target_path"]) + } + if seenFileSize != "5" { + t.Fatalf("expected x-file-size=5, got %q", seenFileSize) + } + if !strings.HasPrefix(seenContentType, "multipart/form-data; boundary=") { + t.Fatalf("expected multipart content type, got %q", seenContentType) + } + if !strings.Contains(seenBody, `name="file"; filename="demo.txt"`) { + t.Fatalf("expected file part in upload body: %q", seenBody) + } +} + +func TestUploadFileWaitsForProcessedFetchFiles(t *testing.T) { + oldSleep := fileReadySleep + fileReadySleep = func(time.Duration) {} + defer func() { fileReadySleep = oldSleep }() + + challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42")) + powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + DeepSeekUploadTargetPath + `"}}}}` + uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}}}}` + pendingFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"PENDING","purpose":"assistants","is_image":false}]}}}` + processedFetchResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"files":[{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"processed","purpose":"assistants","is_image":true}]}}}` + + var call int + client := &Client{ + regular: doerFunc(func(req *http.Request) (*http.Response, error) { + call++ + switch call { + case 1: + bodyBytes, _ := io.ReadAll(req.Body) + if !strings.Contains(string(bodyBytes), `"target_path":"`+DeepSeekUploadTargetPath+`"`) { + t.Fatalf("expected pow target path request, got %s", string(bodyBytes)) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil + case 2: + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil + case 3, 4: + if req.Method != http.MethodGet { + t.Fatalf("expected GET fetch request, got %s", req.Method) + } + if req.URL.Path != "/api/v0/file/fetch_files" { + t.Fatalf("expected fetch files path /api/v0/file/fetch_files, got %q", req.URL.Path) + } + if got := req.URL.Query().Get("file_ids"); got != "file_789" { + t.Fatalf("expected file_ids=file_789, got %q", got) + } + respBody := pendingFetchResponse + if call == 4 { + respBody = processedFetchResponse + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(respBody)), Request: req}, nil + default: + t.Fatalf("unexpected request count %d", call) + return nil, nil + } + }), + fallback: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return nil, nil })}, + maxRetries: 1, + } + + result, err := client.UploadFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token", TriedAccounts: map[string]bool{}}, UploadFileRequest{ + Filename: "demo.txt", + ContentType: "text/plain", + Purpose: "assistants", + Data: []byte("hello"), + }, 1) + if err != nil { + t.Fatalf("UploadFile error: %v", err) + } + if result.ID != "file_789" { + t.Fatalf("expected uploaded file id file_789, got %#v", result) + } + if result.Status != "processed" { + t.Fatalf("expected final status processed, got %#v", result.Status) + } + if call != 4 { + t.Fatalf("expected 4 requests, got %d", call) + } +} diff --git a/internal/deepseek/constants.go b/internal/deepseek/constants.go index 2303abb3..577725f2 100644 --- a/internal/deepseek/constants.go +++ b/internal/deepseek/constants.go @@ -12,9 +12,13 @@ const ( DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge" DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion" DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue" + DeepSeekUploadFileURL = "https://chat.deepseek.com/api/v0/file/upload_file" + DeepSeekFetchFilesURL = "https://chat.deepseek.com/api/v0/file/fetch_files" DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page" DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete" DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all" + DeepSeekCompletionTargetPath = "/api/v0/chat/completion" + DeepSeekUploadTargetPath = "/api/v0/file/upload_file" ) var defaultBaseHeaders = map[string]string{ diff --git a/internal/deepseek/constants_shared.json b/internal/deepseek/constants_shared.json index 7d43eba2..fb58d0ef 100644 --- a/internal/deepseek/constants_shared.json +++ b/internal/deepseek/constants_shared.json @@ -3,7 +3,6 @@ "Host": "chat.deepseek.com", "User-Agent": "DeepSeek/1.8.0 Android/35", "Accept": "application/json", - "Content-Type": "application/json", "x-client-platform": "android", "x-client-version": "1.8.0", "x-client-locale": "zh_CN", diff --git a/internal/deepseek/prompt.go b/internal/deepseek/prompt.go index 24103902..77fd36f8 100644 --- a/internal/deepseek/prompt.go +++ b/internal/deepseek/prompt.go @@ -5,3 +5,7 @@ import "ds2api/internal/prompt" func MessagesPrepare(messages []map[string]any) string { return prompt.MessagesPrepare(messages) } + +func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string { + return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled) +} diff --git a/internal/prompt/messages.go b/internal/prompt/messages.go index fe69f722..91a3b840 100644 --- a/internal/prompt/messages.go +++ b/internal/prompt/messages.go @@ -10,6 +10,7 @@ import ( var markdownImagePattern = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`) const ( + beginSentenceMarker = "<|begin▁of▁sentence|>" systemMarker = "<|System|>" userMarker = "<|User|>" assistantMarker = "<|Assistant|>" @@ -17,9 +18,15 @@ const ( endSentenceMarker = "<|end▁of▁sentence|>" endToolResultsMarker = "<|end▁of▁toolresults|>" endInstructionsMarker = "<|end▁of▁instructions|>" + openThinkMarker = "" + closeThinkMarker = "" ) func MessagesPrepare(messages []map[string]any) string { + return MessagesPrepareWithThinking(messages, false) +} + +func MessagesPrepareWithThinking(messages []map[string]any, thinkingEnabled bool) string { type block struct { Role string Text string @@ -41,11 +48,14 @@ func MessagesPrepare(messages []map[string]any) string { } merged = append(merged, msg) } - parts := make([]string, 0, len(merged)) + parts := make([]string, 0, len(merged)+2) + parts = append(parts, beginSentenceMarker) + lastRole := "" for _, m := range merged { + lastRole = m.Role switch m.Role { case "assistant": - parts = append(parts, formatRoleBlock(assistantMarker, m.Text, endSentenceMarker)) + parts = append(parts, formatRoleBlock(assistantMarker, closeThinkMarker+m.Text, endSentenceMarker)) case "tool": if strings.TrimSpace(m.Text) != "" { parts = append(parts, formatRoleBlock(toolMarker, m.Text, endToolResultsMarker)) @@ -62,6 +72,13 @@ func MessagesPrepare(messages []map[string]any) string { } } } + if lastRole != "assistant" { + thinkPrefix := closeThinkMarker + if thinkingEnabled { + thinkPrefix = openThinkMarker + } + parts = append(parts, assistantMarker+thinkPrefix) + } out := strings.Join(parts, "\n\n") return markdownImagePattern.ReplaceAllString(out, `[${1}](${2})`) } diff --git a/internal/prompt/messages_test.go b/internal/prompt/messages_test.go index 5465c7af..a86f9db0 100644 --- a/internal/prompt/messages_test.go +++ b/internal/prompt/messages_test.go @@ -32,13 +32,16 @@ func TestMessagesPrepareUsesTurnSuffixes(t *testing.T) { {"role": "assistant", "content": "Answer"}, } got := MessagesPrepare(messages) + if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { + t.Fatalf("expected begin-of-sentence marker, got %q", got) + } if !strings.Contains(got, "<|System|>\nSystem rule<|end▁of▁instructions|>") { t.Fatalf("expected system instructions suffix, got %q", got) } if !strings.Contains(got, "<|User|>\nQuestion<|end▁of▁sentence|>") { t.Fatalf("expected user sentence suffix, got %q", got) } - if !strings.Contains(got, "<|Assistant|>\nAnswer<|end▁of▁sentence|>") { + if !strings.Contains(got, "<|Assistant|>\nAnswer<|end▁of▁sentence|>") { t.Fatalf("expected assistant sentence suffix, got %q", got) } } @@ -51,3 +54,11 @@ func TestNormalizeContentArrayFallsBackToContentWhenTextEmpty(t *testing.T) { t.Fatalf("expected fallback to content when text is empty, got %q", got) } } + +func TestMessagesPrepareWithThinkingEndsWithOpenThink(t *testing.T) { + messages := []map[string]any{{"role": "user", "content": "Question"}} + got := MessagesPrepareWithThinking(messages, true) + if !strings.HasSuffix(got, "<|Assistant|>") { + t.Fatalf("expected thinking suffix, got %q", got) + } +} diff --git a/internal/util/messages_test.go b/internal/util/messages_test.go index 1fd20240..9a09a267 100644 --- a/internal/util/messages_test.go +++ b/internal/util/messages_test.go @@ -12,7 +12,7 @@ func TestMessagesPrepareBasic(t *testing.T) { if got == "" { t.Fatal("expected non-empty prompt") } - if got != "<|User|>\nHello<|end▁of▁sentence|>" { + if got != "<|begin▁of▁sentence|>\n\n<|User|>\nHello<|end▁of▁sentence|>\n\n<|Assistant|>" { t.Fatalf("unexpected prompt: %q", got) } } @@ -29,10 +29,13 @@ func TestMessagesPrepareRoles(t *testing.T) { if !contains(got, "<|System|>\nYou are helper<|end▁of▁instructions|>\n\n<|User|>\nHi<|end▁of▁sentence|>") { t.Fatalf("expected system/user separation in %q", got) } - if !contains(got, "<|User|>\nHi<|end▁of▁sentence|>\n\n<|Assistant|>\nHello<|end▁of▁sentence|>") { + if !contains(got, "<|begin▁of▁sentence|>") { + t.Fatalf("expected begin marker in %q", got) + } + if !contains(got, "<|User|>\nHi<|end▁of▁sentence|>\n\n<|Assistant|>\nHello<|end▁of▁sentence|>") { t.Fatalf("expected user/assistant separation in %q", got) } - if !contains(got, "<|Assistant|>\nHello<|end▁of▁sentence|>\n\n<|Tool|>\nSearch results<|end▁of▁toolresults|>") { + if !contains(got, "<|Assistant|>\nHello<|end▁of▁sentence|>\n\n<|Tool|>\nSearch results<|end▁of▁toolresults|>") { t.Fatalf("expected assistant/tool separation in %q", got) } if !contains(got, "<|Tool|>\nSearch results<|end▁of▁toolresults|>\n\n<|User|>\nHow are you<|end▁of▁sentence|>") { @@ -74,7 +77,7 @@ func TestMessagesPrepareArrayTextVariants(t *testing.T) { }, } got := MessagesPrepare(messages) - if got != "<|User|>\nline1\nline2<|end▁of▁sentence|>" { + if got != "<|begin▁of▁sentence|>\n\n<|User|>\nline1\nline2<|end▁of▁sentence|>\n\n<|Assistant|>" { t.Fatalf("unexpected content from text variants: %q", got) } } diff --git a/internal/util/standard_request.go b/internal/util/standard_request.go index d0c386e9..2071fbea 100644 --- a/internal/util/standard_request.go +++ b/internal/util/standard_request.go @@ -14,6 +14,7 @@ type StandardRequest struct { Stream bool Thinking bool Search bool + RefFileIDs []string PassThrough map[string]any } @@ -61,12 +62,19 @@ func (r StandardRequest) CompletionPayload(sessionID string) map[string]any { if resolvedType, ok := config.GetModelType(modelID); ok { modelType = resolvedType } + refFileIDs := make([]any, 0, len(r.RefFileIDs)) + for _, fileID := range r.RefFileIDs { + if fileID == "" { + continue + } + refFileIDs = append(refFileIDs, fileID) + } payload := map[string]any{ "chat_session_id": sessionID, "model_type": modelType, "parent_message_id": nil, "prompt": r.FinalPrompt, - "ref_file_ids": []any{}, + "ref_file_ids": refFileIDs, "thinking_enabled": r.Thinking, "search_enabled": r.Search, } diff --git a/internal/util/standard_request_test.go b/internal/util/standard_request_test.go index 93af9b77..f4846054 100644 --- a/internal/util/standard_request_test.go +++ b/internal/util/standard_request_test.go @@ -22,6 +22,7 @@ func TestStandardRequestCompletionPayloadSetsModelTypeFromResolvedModel(t *testi FinalPrompt: "hello", Thinking: tc.thinking, Search: tc.search, + RefFileIDs: []string{"file-a", "file-b"}, PassThrough: map[string]any{ "temperature": 0.3, }, @@ -44,6 +45,13 @@ func TestStandardRequestCompletionPayloadSetsModelTypeFromResolvedModel(t *testi if got := payload["temperature"]; got != 0.3 { t.Fatalf("expected passthrough temperature, got %#v", got) } + refFileIDs, ok := payload["ref_file_ids"].([]any) + if !ok { + t.Fatalf("expected ref_file_ids slice, got %#v", payload["ref_file_ids"]) + } + if len(refFileIDs) != 2 || refFileIDs[0] != "file-a" || refFileIDs[1] != "file-b" { + t.Fatalf("unexpected ref_file_ids: %#v", refFileIDs) + } }) } } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 621df2f1..4b0dda5e 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -162,7 +162,7 @@ func TestMessagesPrepareMergesConsecutiveSameRole(t *testing.T) { {"role": "user", "content": "World"}, } got := MessagesPrepare(messages) - if !strings.HasPrefix(got, "<|User|>") { + if !strings.HasPrefix(got, "<|begin▁of▁sentence|>") { t.Fatalf("expected user marker at the start, got %q", got) } if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") { @@ -193,7 +193,7 @@ func TestMessagesPrepareAssistantMarkers(t *testing.T) { if strings.Count(got, "<|end▁of▁sentence|>") != 2 { t.Fatalf("expected both turns to be terminated, got %q", got) } - if !strings.Contains(got, "<|Assistant|>\nHello!<|end▁of▁sentence|>") { + if !strings.Contains(got, "<|Assistant|>\nHello!<|end▁of▁sentence|>") { t.Fatalf("expected assistant EOS suffix, got %q", got) } if strings.Contains(got, "") { diff --git a/opencode.json.example b/opencode.json.example deleted file mode 100644 index ed18a631..00000000 --- a/opencode.json.example +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "ds2api": { - "npm": "@ai-sdk/openai-compatible", - "name": "DS2API", - "options": { - "baseURL": "http://localhost:5001/v1", - "apiKey": "your-api-key" - }, - "models": { - "gpt-4o": { - "name": "GPT-4o (aliased to deepseek-chat)" - }, - "gpt-5-codex": { - "name": "GPT-5 Codex (aliased to deepseek-reasoner)" - }, - "deepseek-chat": { - "name": "DeepSeek Chat (DS2API)" - }, - "deepseek-reasoner": { - "name": "DeepSeek Reasoner (DS2API)" - } - } - } - }, - "model": "ds2api/gpt-5-codex" -} diff --git a/webui/src/features/apiTester/ApiTesterContainer.jsx b/webui/src/features/apiTester/ApiTesterContainer.jsx index f3485c37..96e824a7 100644 --- a/webui/src/features/apiTester/ApiTesterContainer.jsx +++ b/webui/src/features/apiTester/ApiTesterContainer.jsx @@ -14,6 +14,8 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) { setModel, message, setMessage, + attachedFiles, + setAttachedFiles, apiKey, setApiKey, selectedAccount, @@ -70,6 +72,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) { effectiveKey, selectedAccount, streamingMode, + attachedFiles, abortControllerRef, setLoading, setIsStreaming, @@ -79,7 +82,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) { }) return ( -
+
{ + const files = Array.from(e.target.files) + if (files.length === 0) return + + if (!effectiveKey) { + onMessage('error', t('apiTester.missingApiKey') || 'Missing API Key') + return + } + + setUploadingFiles(true) + const initialSelectedAccount = String(selectedAccount || '').trim() + let boundAccount = initialSelectedAccount + for (const file of files) { + const formData = new FormData() + formData.append('file', file) + formData.append('purpose', 'assistants') + + const headers = { + 'Authorization': `Bearer ${effectiveKey}`, + } + if (boundAccount) { + headers['X-Ds2-Target-Account'] = boundAccount + } + + try { + const res = await fetch('/v1/files', { + method: 'POST', + headers, + body: formData + }) + if (!res.ok) { + const err = await res.text() + onMessage('error', err || 'File upload failed') + continue + } + const data = await res.json() + setAttachedFiles(prev => [...prev, data]) + const uploadedAccount = String(data?.account_id || '').trim() + if (!boundAccount && uploadedAccount) { + boundAccount = uploadedAccount + } + } catch (error) { + onMessage('error', error.message || 'Network error during upload') + } + } + setUploadingFiles(false) + if (!initialSelectedAccount && boundAccount && setSelectedAccount) { + setSelectedAccount(boundAccount) + } + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const removeFile = (id) => { + setAttachedFiles(prev => prev.filter(f => f.id !== id)) + } + + const attachmentAccountIds = getAttachedFileAccountIds(attachedFiles) + const attachmentAccountId = attachmentAccountIds.length === 1 ? attachmentAccountIds[0] : '' return (
@@ -61,7 +133,9 @@ export default function ChatPanel({ )}
- {streamingContent || response?.choices?.[0]?.message?.content || (response?.error && {response.error}) || (loading && {t('apiTester.generating')})} + {response?.success === false + ? {response.error || t('apiTester.requestFailed')} + : (streamingContent || response?.choices?.[0]?.message?.content || (loading && {t('apiTester.generating')}))} {isStreaming && }
@@ -70,9 +144,52 @@ export default function ChatPanel({
+ {attachedFiles.length > 0 && ( +
+ {attachedFiles.map(file => ( +
+ + {file.filename || file.id} + +
+ ))} +
+ )} + {attachmentAccountIds.length > 1 && ( +
+ {t('apiTester.fileAccountConflict')} +
+ )} + {attachmentAccountId && ( +
+ {t('apiTester.attachmentAccountHint', { account: attachmentAccountId })} +
+ )}
+ +
+ +