ds2api / API.en.md
luckfun233
feat(prompt): 新增 prompt_style 风格档位与自定义模型列表
fcdd4aa
|
Raw History Blame
74.4 kB
# DS2API API Reference
Language: [中文](API.md) | [English](API.en.md)
This document describes the actual behavior of the current Go codebase.
Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Deployment](docs/DEPLOY.en.md) / [Testing](docs/TESTING.md) / [Prompt compatibility flow](docs/prompt-compatibility.md)
---
## Table of Contents
- [Basics](#basics)
- [Configuration Best Practice](#configuration-best-practice)
- [Authentication](#authentication)
- [Route Index](#route-index)
- [Health Endpoints](#health-endpoints)
- [OpenAI-Compatible API](#openai-compatible-api)
- [Claude-Compatible API](#claude-compatible-api)
- [Gemini-Compatible API](#gemini-compatible-api)
- [Ollama API](#ollama-api)
- [Admin API](#admin-api)
- [Error Payloads](#error-payloads)
- [cURL Examples](#curl-examples)
---
## Basics
| Item | Details |
| --- | --- |
| Base URL | `http://localhost:5001` or your deployment domain |
| Default Content-Type | `application/json` |
| Health probes | `GET /healthz`, `GET /readyz` |
| CORS | Enabled (uniformly covers `/v1/*`, `/anthropic/*`, `/v1beta/models/*`, `/api/*`, and `/admin/*`; echoes the browser `Origin` when present, otherwise `*`; default allow-list includes `Content-Type`, `Authorization`, `X-API-Key`, `X-Ds2-Target-Account`, `X-Ds2-Source`, `X-Vercel-Protection-Bypass`, `X-Goog-Api-Key`, `Anthropic-Version`, `Anthropic-Beta`, and also accepts third-party preflight-requested headers such as `x-stainless-*`; `/v1/chat/completions` on Vercel Node Runtime matches the same behavior; internal-only `X-Ds2-Internal-Token` remains blocked) |
- All JSON request bodies must be valid UTF-8; malformed byte sequences are rejected on ingress with `400 invalid json`.
### 3.0 Adapter-Layer Notes
- OpenAI / Claude / Gemini protocols are now mounted on one shared `chi` router tree assembled in `internal/server/router.go`.
- Adapter responsibilities are streamlined to: **request normalization → DeepSeek invocation → protocol-shaped rendering**, reducing legacy split-logic paths.
- The full path from a protocol request to the pure-text web-chat prompt (normalized standard request, thinking-format injection, history split, completion payload assembly, response archiving) is documented in [docs/prompt-compatibility.md](docs/prompt-compatibility.md), which is the source-of-truth document for that flow; this document only describes the visible fields and response shapes of each endpoint.
- Tool-calling semantics are aligned between Go and Node runtime: models should output the halfwidth-pipe DSML shell `<|DSML|tool_calls>` → `<|DSML|invoke name="...">` → `<|DSML|parameter name="...">`; DS2API also accepts DSML wrapper aliases such as `<dsml|tool_calls>` and `<|tool_calls>`, common DSML separator drift such as `<|DSML tool_calls>`, collapsed DSML local names such as `<DSMLtool_calls>`, control-separator drift such as `<DSML␂tool_calls>` / raw STX `\x02`, CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift such as `<DSM|parameter name="command"|>...〈/DSM|parameter〉`, `<!DSML!invoke name=“Bash”>`, `<、DSML、tool_calls>`, `<DSmartToolCalls>`, or `<DSMLtool_calls※>`, arbitrary protocol prefixes such as `<proto💥tool_calls>`, and legacy canonical XML `<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`. The scanner normalizes fixed local names (`tool_calls` / `invoke` / `parameter`) with non-structural separators before or after them back to XML before parsing, and also tolerates CDATA opener drift such as `<![CDATA[` / `<、[CDATA[`; only wrapped tool blocks or the narrow missing-opening-wrapper repair path enter the tool path, while bare `<invoke>` does not count as supported syntax. JSON literal parameter bodies are preserved as structured values, explicit empty or whitespace-only parameters are preserved as empty strings, malformed complete wrappers are released as plain text, and loose CDATA is narrowly repaired at final parse/flush when it can preserve a complete outer tool call.
- `Admin API` separates static config from runtime policy: `/admin/config*` for configuration state, `/admin/settings*` for runtime behavior.
- When upstream returns a thinking-only response with no visible text, the Go main path and the Vercel Node streaming path retry once in the same DeepSeek session: it appends the prompt suffix `"Please provide a non-empty final answer or tool call."` and sets `parent_message_id`. If that same-account retry would still end as `429 upstream_empty_output`, managed-account mode switches to the next available account, creates a fresh session, and retries the original payload once before returning 429.
- Citation/reference marker boundary: streaming output hides upstream `[citation:N]` / `[reference:N]` placeholders by default; non-stream output converts DeepSeek search reference markers into Markdown links.
---
## Configuration Best Practice
Use `config.json` as the single source of truth:
```bash
cp config.example.json config.json
# Edit config.json (keys/accounts)
```
Use it per deployment mode:
- Local run: read `config.json` directly
- Docker / Vercel: generate Base64 from `config.json`, then set `DS2API_CONFIG_JSON`, or paste raw JSON directly
```bash
DS2API_CONFIG_JSON="$(base64 < config.json | tr -d '\n')"
```
For Vercel one-click bootstrap, you can set only `DS2API_ADMIN_KEY` first, then import config at `/admin` and sync env vars from the "Vercel Sync" page.
---
## Authentication
### Business Endpoints (`/v1/*`, `/anthropic/*`, `/v1beta/models/*`)
Two header formats accepted:
| Method | Example |
| --- | --- |
| Bearer Token | `Authorization: Bearer <token>` |
| API Key Header | `x-api-key: <token>` (no `Bearer` prefix) |
| Gemini-compatible | `x-goog-api-key: <token>` or `?key=<token>` / `?api_key=<token>` |
**Auth behavior**:
- Token is in `config.keys` → **Managed account mode**: DS2API auto-selects an account via rotation
- Token is not in `config.keys` → **Direct token mode**: treated as a DeepSeek token directly
**Optional header**: `X-Ds2-Target-Account: <email_or_mobile>` — Pin a specific managed account; if the target account does not exist or the managed-account queue is exhausted, the request returns `429`, and current responses do not include `Retry-After`. If the account exists but login/refresh fails, the request returns the underlying `401` or upstream error. Without a pinned target, managed-account completion requests try one alternate-account fresh retry before returning an empty-output 429; pinned-target requests and requests with no other available account do not switch.
Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=` as the caller credential source.
### Admin Endpoints (`/admin/*`)
| Endpoint | Auth |
| --- | --- |
| `POST /admin/login` | Public |
| `GET /admin/verify` | `Authorization: Bearer <jwt>` (JWT only) |
| Other `/admin/*` | `Authorization: Bearer <jwt>` or `Authorization: Bearer <admin_key>` |
---
## Route Index
| Method | Path | Auth | Description |
| --- | --- | --- | --- |
| GET | `/healthz` | None | Liveness probe |
| HEAD | `/healthz` | None | Liveness probe (no body) |
| GET | `/readyz` | None | Readiness probe |
| HEAD | `/readyz` | None | Readiness probe (no body) |
| GET | `/v1/models` | None | OpenAI model list |
| GET | `/v1/models/{id}` | None | OpenAI single-model query (alias accepted) |
| POST | `/v1/chat/completions` | Business | OpenAI chat completions |
| POST | `/v1/responses` | Business | OpenAI Responses API (stream/non-stream) |
| GET | `/v1/responses/{response_id}` | Business | Query stored response (in-memory TTL) |
| POST | `/v1/embeddings` | Business | OpenAI Embeddings API |
| POST | `/v1/files` | Business | OpenAI Files upload (multipart/form-data) |
| GET | `/v1/files/{file_id}` | Business | Retrieve uploaded file status |
| GET | `/anthropic/v1/models` | None | Claude model list |
| POST | `/anthropic/v1/messages` | Business | Claude messages |
| POST | `/anthropic/v1/messages/count_tokens` | Business | Claude token counting |
| POST | `/v1/messages` | Business | Claude shortcut path |
| POST | `/messages` | Business | Claude shortcut path |
| POST | `/v1/messages/count_tokens` | Business | Claude token counting shortcut |
| POST | `/messages/count_tokens` | Business | Claude token counting shortcut |
| POST | `/v1beta/models/{model}:generateContent` | Business | Gemini non-stream |
| POST | `/v1beta/models/{model}:streamGenerateContent` | Business | Gemini stream |
| POST | `/v1/models/{model}:generateContent` | Business | Gemini non-stream compat path |
| POST | `/v1/models/{model}:streamGenerateContent` | Business | Gemini stream compat path |
| GET | `/api/version` | None | Ollama version endpoint |
| GET | `/api/tags` | None | Ollama model list |
| POST | `/api/show` | None | Ollama model capability query (returns `id` + `capabilities`) |
| POST | `/admin/login` | None | Admin login |
| GET | `/admin/verify` | JWT | Verify admin JWT |
| GET | `/admin/vercel/config` | Admin | Read preconfigured Vercel creds |
| GET | `/admin/config` | Admin | Read sanitized config |
| POST | `/admin/config` | Admin | Update config |
| GET | `/admin/settings` | Admin | Read runtime settings |
| PUT | `/admin/settings` | Admin | Update runtime settings (hot reload) |
| POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs |
| GET | `/admin/skills` | Admin | Read the skill library config (global switch, truncation limit, items) |
| PUT | `/admin/skills` | Admin | Replace the whole skill library config (`400` on validation failure) |
| POST | `/admin/config/import` | Admin | Import config (merge/replace) |
| GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) |
| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) |
| PUT | `/admin/keys/{key}` | Admin | Update API key metadata |
| DELETE | `/admin/keys/{key}` | Admin | Delete API key |
| GET | `/admin/proxies` | Admin | List proxies |
| POST | `/admin/proxies` | Admin | Add proxy |
| PUT | `/admin/proxies/{proxyID}` | Admin | Update proxy (empty password keeps old secret) |
| DELETE | `/admin/proxies/{proxyID}` | Admin | Delete proxy (auto-unbind referenced accounts) |
| POST | `/admin/proxies/test` | Admin | Test proxy connectivity |
| GET | `/admin/accounts` | Admin | Paginated account list |
| POST | `/admin/accounts` | Admin | Add account |
| PUT | `/admin/accounts/{identifier}` | Admin | Update account name/remark |
| DELETE | `/admin/accounts/{identifier}` | Admin | Delete account |
| PUT | `/admin/accounts/{identifier}/proxy` | Admin | Bind/unbind proxy for an account |
| GET | `/admin/queue/status` | Admin | Account queue status |
| POST | `/admin/accounts/test` | Admin | Test one account |
| POST | `/admin/accounts/test-all` | Admin | Test all accounts |
| POST | `/admin/accounts/sessions/delete-all` | Admin | Delete all sessions for one account |
| POST | `/admin/import` | Admin | Batch import keys/accounts |
| POST | `/admin/test` | Admin | Test API through service |
| POST | `/admin/dev/raw-samples/capture` | Admin | Fire one request and persist it as a raw sample |
| GET | `/admin/dev/raw-samples/query` | Admin | Search current in-memory capture chains by prompt keyword |
| POST | `/admin/dev/raw-samples/save` | Admin | Persist a selected in-memory capture chain as a raw sample |
| POST | `/admin/vercel/sync` | Admin | Sync config to Vercel |
| GET | `/admin/vercel/status` | Admin | Vercel sync status |
| POST | `/admin/vercel/status` | Admin | Vercel sync status / draft compare |
| GET | `/admin/export` | Admin | Export config JSON/Base64 |
| GET | `/admin/dev/captures` | Admin | Read local packet-capture entries |
| DELETE | `/admin/dev/captures` | Admin | Clear local packet-capture entries |
| GET | `/admin/chat-history` | Admin | Read server-side conversation history (filters by caller/status/surface/keyword plus paging) |
| DELETE | `/admin/chat-history` | Admin | Clear server-side conversation history |
| GET | `/admin/chat-history/{id}` | Admin | Read one server-side conversation entry |
| DELETE | `/admin/chat-history/{id}` | Admin | Delete one server-side conversation entry |
| PUT | `/admin/chat-history/settings` | Admin | Update conversation history retention limit (`0`..`500`) |
| GET | `/admin/chat-history/export` | Admin | Export conversation history with the same filters (`json` / `markdown`) |
| GET | `/admin/version` | Admin | Check current version and latest Release |
OpenAI `/v1/*` paths are canonical. For clients configured with the bare DS2API service URL, the same OpenAI handlers are also exposed through root shortcuts: `/models`, `/models/{id}`, `/chat/completions`, `/responses`, `/responses/{response_id}`, `/embeddings`, `/files`, and `/files/{file_id}`.
Server-side records are essentially an archive of DeepSeek upstream responses: OpenAI Chat, OpenAI Responses, Claude Messages, and Gemini GenerateContent write an entry as soon as the upstream response arrives, before protocol translation/trimming. The list is ordered by request creation time (newest first), and streaming requests refresh status and detail while generating. Requests issued by the WebUI "API test" panel also land there. Each entry carries `caller_id` (the calling API key) plus `caller_label` (key name/remark/masked value), and the filterable `surface`, `status`, and `status_code` fields; list, detail, and export all read the same data (see [conversation history](#get-adminchat-history)).
---
## Health Endpoints
### `GET /healthz`
```json
{"status": "ok"}
```
### `GET /readyz`
```json
{"status": "ready"}
```
---
## OpenAI-Compatible API
### `GET /v1/models`
No auth required. Returns the currently supported DeepSeek native model list.
**Response**:
```json
{
"object": "list",
"data": [
{"id": "deepseek-v4-flash", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-nothinking", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []},
{"id": "deepseek-v4-flash-search-nothinking", "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. A `-nothinking` suffix means thinking/reasoning is always off, regardless of what the request asks for.
>
> The model line has collapsed to the four flash variants above: `deepseek-v4-pro` / `deepseek-v4-pro-search` / `deepseek-v4-vision` and their `-nothinking` forms are retired and no longer appear in this endpoint, Ollama `/api/tags`, or `/anthropic/v1/models`. Old names still work as request input and are silently mapped to the matching flash model (see the next section).
### Model Alias Resolution
For `chat` / `responses` / `embeddings`, DS2API follows a wide-input/strict-output policy:
1. Match DeepSeek native model IDs first.
2. Then match exact keys in `model_aliases`.
3. If the request name ends with `-nothinking`, resolve the base alias and append the corresponding no-thinking variant.
4. If still unmatched, return `invalid_request_error`. Unknown model families are not guessed heuristically; add explicit compatibility names through `model_aliases`.
Built-in aliases come from `internal/config/models.go`; `config.model_aliases` can override or add mappings at runtime. Excerpt (all pointing at the flash models):
- OpenAI / Codex: `gpt-4o`, `gpt-4.1`, `gpt-5`, `gpt-5.5`, `gpt-5-codex`, `gpt-5.3-codex`, `codex-mini-latest` → `deepseek-v4-flash`
- OpenAI reasoning: `o1`, `o3`, `o4-mini` → `deepseek-v4-flash`; `o3-deep-research`, `o4-mini-deep-research` → `deepseek-v4-flash-search`
- Claude: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-3-5-sonnet-latest` → `deepseek-v4-flash`
- Gemini: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-3.1-pro`, `gemini-3-pro`, `gemini-3-flash`, `gemini-3.1-flash-lite`, `gemini-pro-vision` → `deepseek-v4-flash`
- Other exact built-in aliases: `llama-3.1-70b-instruct`, `qwen-max` → `deepseek-v4-flash`
Aliases with a `-nothinking` suffix also map to the corresponding forced no-thinking DeepSeek model.
Retired DeepSeek model names keep old clients working; they are normalized silently at parse time:
| Requested model | Resolved to |
| --- | --- |
| `deepseek-v4-pro`, `deepseek-v4-vision` | `deepseek-v4-flash` |
| `deepseek-v4-pro-search` | `deepseek-v4-flash-search` |
| `deepseek-v4-pro-nothinking`, `deepseek-v4-vision-nothinking` | `deepseek-v4-flash-nothinking` |
| `deepseek-v4-pro-search-nothinking` | `deepseek-v4-flash-search-nothinking` |
The upstream `model_type` is now only `default` (a legacy `expert` / `vision` value is treated as `default`), so there is no separate vision or expert lane.
Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant-*`, and `gpt-3.5*` are explicitly rejected.
### Thinking and search resolution
The same rules apply to `/v1/chat/completions`, `/v1/responses`, `/anthropic/v1/messages`, and Gemini `generateContent`; they live in `internal/util/thinking.go` and `internal/util/search.go`, not in the protocol adapters.
**Thinking**, lowest priority first:
| Order | Source | Notes |
| --- | --- | --- |
| 1 | Model default | `-nothinking` variants default off; other flash models default on |
| 2 | Explicit client override | Any matching field wins |
| 3 | `-nothinking` model | Forces thinking off and outranks the client; a client cannot turn it back on |
Client override fields (the same keys inside `extra_body` are read too):
| Field | Accepted forms |
| --- | --- |
| `thinking` | bool; `"enabled"` / `"disabled"` (also `on` / `off` / `true` / `false` / `none`); `{"type": ...}` |
| `reasoning` | `{"effort": ...}`, `{"type": ...}`, `{"enabled": ...}`; a plain string is parsed as an effort value |
| `reasoning_effort` | `minimal` / `low` / `medium` / `high` / `xhigh` / `max` = on; `none` / `disabled` / `disable` / `off` / `false` = off |
Clients such as Kelivo translate their "thinking intensity" UI into `reasoning_effort`: omitting it follows the default (thinking on), sending `none` turns thinking off. DS2API only models on/off; the intensity tier itself is not forwarded.
**Search**, resolved as "model default → explicit client signal wins":
| Signal | Notes |
| --- | --- |
| `enable_search` | bool, or strings such as `"true"` / `"false"` / `"on"` / `"off"` / `"enabled"` / `"disabled"` |
| `web_search_options` | Presence alone counts as on |
| `search_options` | Presence alone counts as on |
| Native search tool in `tools` | `type` starting with `web_search`, `type=google_search`, or a tool object carrying `googleSearch` / `google_search` / `web_search` |
`-search` variants enable native search by default; an explicit `enable_search=false` turns it off for that request. Ordinary function tools (for example Kelivo's own `search_web`) are not search signals, so upstream search stays off by default — pick a `-search` model when you need it. Once a client starts sending the native signals above, DS2API follows automatically with no configuration change.
### `POST /v1/chat/completions`
> Path note: besides the canonical `/v1/chat/completions`, DS2API also accepts the root shortcut `/chat/completions`. On Vercel Runtime, `vercel.json` rewrites only the canonical `/v1/chat/completions` path to the Node streaming bridge; the root shortcut stays on the Go primary path. Use `/v1/chat/completions` on Vercel when real-time streaming is required.
**Headers**:
```http
Authorization: Bearer your-api-key
Content-Type: application/json
```
**Request body**:
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `model` | string | ✅ | DeepSeek native models + common aliases (`gpt-5.5`, `gpt-5.4-mini`, `gpt-5.3-codex`, `o3`, `claude-opus-4-6`, `gemini-2.5-pro`, `gemini-3.1-pro`, `gemini-3-flash`, etc.); `-nothinking` suffixes force thinking / reasoning off |
| `messages` | array | ✅ | OpenAI-style messages |
| `stream` | boolean | ❌ | Default `false` |
| `tools` | array | ❌ | Function calling schema |
| `thinking` | bool / string / object | ❌ | Explicit thinking switch that overrides the model default; ignored by `-nothinking` models |
| `reasoning` / `reasoning_effort` | object / string | ❌ | Alternative spelling of `thinking`; `reasoning_effort=none` turns thinking off, `minimal`..`max` turns it on |
| `enable_search` | bool / string | ❌ | Explicit search switch that overrides the model default (e.g. turn search off on a `-search` model) |
| `temperature`, etc. | any | ❌ | Accepted but final behavior depends on upstream |
> Full precedence for thinking and search: [Thinking and search resolution](#thinking-and-search-resolution).
#### Non-Stream Response
```json
{
"id": "<chat_session_id>",
"object": "chat.completion",
"created": 1738400000,
"model": "deepseek-v4-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "final response",
"reasoning_content": "reasoning trace (when thinking is enabled)"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
"completion_tokens_details": {
"reasoning_tokens": 5
}
}
}
```
#### Streaming (`stream=true`)
SSE format: each frame is `data: <json>\n\n`, terminated by `data: [DONE]`.
```text
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"reasoning_content":"..."},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"..."},"index":0}]}
data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{...}}
data: [DONE]
```
**Field notes**:
- First delta includes `role: assistant`
- When thinking is enabled, the stream may emit `delta.reasoning_content`
- Text emits `delta.content`
- Last chunk includes `finish_reason` and `usage`
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent. Failed/interrupted endings (for example `response.failed`) may not include `usage`
#### Tool Calls
> If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls`. The behavior below only applies once tool calling is enabled; see [docs/prompt-compatibility.md](docs/prompt-compatibility.md) for the risk.
When `tools` is present, DS2API performs anti-leak handling:
**Non-stream**: If detected, returns `message.tool_calls`, `finish_reason=tool_calls`, `message.content=null`.
```json
{
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"beijing\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
```
**Stream**: Once high-confidence toolcall features are matched, DS2API emits `delta.tool_calls` immediately (without waiting for full argument closure), then keeps sending argument deltas; confirmed tool-call fragments are not forwarded as `delta.content`.
Additional notes:
- The parser treats the recommended halfwidth-pipe DSML shell tool blocks (`<|DSML|tool_calls>` / `<|DSML|invoke name="...">` / `<|DSML|parameter name="...">`), DSML wrapper aliases (`<dsml|tool_calls>`, `<|tool_calls>`), common DSML separator drift (`<|DSML tool_calls>` / `<|DSML invoke>` / `<|DSML parameter>`), collapsed DSML local names (`<DSMLtool_calls>` / `<DSMLinvoke>` / `<DSMLparameter>`), control-separator drift (`<DSML␂tool_calls>` / raw STX `\x02`), CJK angle bracket, fullwidth-bang / ideographic-comma separator drift, PascalCase local-name drift, and trailing attribute separator drift (`<DSM|parameter name="command"|>...〈/DSM|parameter〉` / `<!DSML!invoke name=“Bash”>` / `<、DSML、tool_calls>` / `<DSmartToolCalls>` / `<DSMLtool_calls※>`), arbitrary protocol prefixes (`<proto💥tool_calls>`), and legacy canonical XML tool blocks (`<tool_calls>` / `<invoke name="...">` / `<parameter name="...">`) as executable tool calls. These shells normalize non-structural separators back to XML first, while internal parsing remains XML-based; CDATA opener drift such as `<![CDATA[` / `<、[CDATA[` is also normalized for parameter bodies. Legacy `<tools>`, `<tool_call>`, `<tool_name>`, `<param>`, `<function_call>`, `tool_use`, antml variants, and standalone JSON `tool_calls` payloads are treated as plain text; complete but malformed wrappers are also released as plain text.
- The parser no longer drops tool calls solely because parameter values are empty; explicit empty strings or whitespace-only parameters become empty strings in structured `tool_calls`. Prompting still tells the model not to emit blank parameters, and missing/empty argument rejection belongs in the tool executor or client schema validation.
- If the final visible response text is empty but the reasoning stream contains an executable tool call, Chat / Responses emits a standard OpenAI `tool_calls` / `function_call` output during finalization. If thinking/reasoning was not enabled by the client, that reasoning text is used only for detection and is not exposed as visible text or `reasoning_content`.
- `tool_calls` shown inside fenced markdown code blocks (for example, ```json ... ```) are treated as examples, not executable calls.
---
### `GET /v1/models/{id}`
No auth required. Alias values are accepted as path params (for example `gpt-4o`), and the returned object is the mapped DeepSeek model.
### `POST /v1/responses`
OpenAI Responses-style endpoint, accepting either `input` or `messages`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `model` | string | ✅ | Supports native models + alias mapping |
| `input` | string/array/object | ❌ | One of `input` or `messages` is required |
| `messages` | array | ❌ | One of `input` or `messages` is required |
| `instructions` | string | ❌ | Prepended as a system message |
| `stream` | boolean | ❌ | Default `false` |
| `tools` | array | ❌ | Same tool detection/translation policy as chat |
| `tool_choice` | string/object | ❌ | Supports `auto`/`none`/`required` and forced function selection (`{"type":"function","name":"..."}`) |
**Non-stream**: Returns a standard `response` object with an ID like `resp_xxx`, and stores it in in-memory TTL cache.
If `tool_choice=required` and no valid tool call is produced, DS2API returns HTTP `422` (`error.code=tool_choice_violation`).
**Stream (SSE)**: minimal event sequence:
```text
event: response.created
data: {"type":"response.created","id":"resp_xxx","status":"in_progress",...}
event: response.output_item.added
data: {"type":"response.output_item.added","response_id":"resp_xxx","item":{"type":"message|function_call",...},...}
event: response.content_part.added
data: {"type":"response.content_part.added","response_id":"resp_xxx","part":{"type":"output_text",...},...}
event: response.output_text.delta
data: {"type":"response.output_text.delta","response_id":"resp_xxx","item_id":"msg_xxx","output_index":0,"content_index":0,"delta":"..."}
event: response.function_call_arguments.delta
data: {"type":"response.function_call_arguments.delta","response_id":"resp_xxx","call_id":"call_xxx","delta":"..."}
event: response.function_call_arguments.done
data: {"type":"response.function_call_arguments.done","response_id":"resp_xxx","call_id":"call_xxx","name":"tool","arguments":"{...}"}
event: response.content_part.done
data: {"type":"response.content_part.done","response_id":"resp_xxx",...}
event: response.output_item.done
data: {"type":"response.output_item.done","response_id":"resp_xxx","item":{"type":"message|function_call",...},...}
event: response.completed
data: {"type":"response.completed","response":{...}}
data: [DONE]
```
If `tool_choice=required` is violated in stream mode, DS2API emits `response.failed` then `[DONE]` (no `response.completed`).
> Current behavior: the parser tries to extract structured tool calls and does not enforce a hard allow-list reject; your tool executor should still validate against a whitelist before executing.
### `GET /v1/responses/{response_id}`
Business auth required. Fetches cached responses created by `POST /v1/responses` (caller-scoped; only the same key/token can read).
> Backed by in-memory TTL store. Default TTL is `900s` (configurable via `responses.store_ttl_seconds`).
### `POST /v1/embeddings`
Business auth required. Returns OpenAI-compatible embeddings shape.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `model` | string | ✅ | Supports native models + alias mapping |
| `input` | string/array | ✅ | Supports string, string array, token array |
> Requires `embeddings.provider`. Current supported values: `mock` / `deterministic` / `builtin` (all three use the same local deterministic implementation). If missing/unsupported, returns standard error shape with HTTP 501.
### `POST /v1/files`
Business auth required. OpenAI Files-compatible upload endpoint; currently only `multipart/form-data` is supported.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `file` | file | ✅ | Binary payload |
| `purpose` | string | ❌ | Forwarded purpose field |
Constraints and behavior:
- `Content-Type` must be `multipart/form-data` (otherwise `400`).
- Total request size limit is **100 MiB** (over-limit returns `413`).
- Success returns an OpenAI `file` object (`id/object/bytes/filename/purpose/status`, etc.) and includes `account_id` for source-account tracing.
### `GET /v1/files/{file_id}`
Business auth required. Retrieves the current DeepSeek upload status for a file and returns an OpenAI `file` object. Returns `404` when no matching file is found.
---
## Claude-Compatible API
Besides `/anthropic/v1/*`, DS2API also supports shortcut paths: `/v1/messages`, `/messages`, `/v1/messages/count_tokens`, `/messages/count_tokens`.
Implementation-wise this path is unified on the OpenAI Chat Completions parse-and-translate pipeline to avoid maintaining divergent parsing chains.
### `GET /anthropic/v1/models`
No auth required.
**Response**:
```json
{
"object": "list",
"data": [
{"id": "claude-sonnet-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-haiku-4-5", "object": "model", "created": 1715635200, "owned_by": "anthropic"},
{"id": "claude-opus-4-6", "object": "model", "created": 1715635200, "owned_by": "anthropic"}
],
"first_id": "claude-opus-4-6",
"last_id": "claude-3-haiku-20240307",
"has_more": false
}
```
> Note: the example is partial; besides the current primary aliases, the real response also includes Claude 4.x snapshots plus historical 3.x IDs and common aliases.
### `POST /anthropic/v1/messages`
**Headers**:
```http
x-api-key: your-api-key
Content-Type: application/json
anthropic-version: 2023-06-01
```
> `anthropic-version` is optional; DS2API auto-fills `2023-06-01` when absent.
**Request body**:
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `model` | string | ✅ | For example `claude-sonnet-4-6` / `claude-opus-4-6` / `claude-haiku-4-5` (compatible with `claude-3-5-haiku-latest`), plus historical Claude model IDs |
| `messages` | array | ✅ | Claude-style messages |
| `max_tokens` | number | ❌ | Auto-filled to `8192` when omitted; not strictly enforced by upstream bridge |
| `stream` | boolean | ❌ | Default `false` |
| `system` | string | ❌ | Optional system prompt |
| `tools` | array | ❌ | Claude tool schema |
| `thinking` | object | ❌ | Anthropic thinking config; translated into downstream reasoning control, and ignored by `-nothinking` models |
| `temperature` | number | ❌ | Passed through to the downstream bridge; if `temperature` and `top_p` are both present, `temperature` wins |
| `top_p` | number | ❌ | Passed through when `temperature` is absent |
| `stop_sequences` | array | ❌ | Passed through as downstream stop sequences |
| `tool_choice` | string/object | ❌ | Supports `auto` / `none` / `required` / `{"type":"function","name":"..."}` and is translated to downstream tool choice |
> Note: `thinking`, `temperature`, `top_p`, `stop_sequences`, and `tool_choice` are translated through the compatibility bridge. Final behavior still depends on the selected model and upstream support. When both `temperature` and `top_p` are present, `temperature` takes precedence. Thinking and search precedence is identical to the OpenAI endpoints; see [Thinking and search resolution](#thinking-and-search-resolution). If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls` (a `tool_use` block on the Claude side).
#### Non-Stream Response
```json
{
"id": "msg_1738400000000000000",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{"type": "text", "text": "response"}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 12,
"output_tokens": 34
}
}
```
If tool use is detected, `stop_reason` becomes `tool_use` and `content` contains `tool_use` blocks.
#### Streaming (`stream=true`)
SSE uses paired `event:` + `data:` lines. Event type is also in JSON `type`.
```text
event: message_start
data: {"type":"message_start","message":{...}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}
event: ping
data: {"type":"ping"}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}
event: message_stop
data: {"type":"message_stop"}
```
**Notes**:
- Models that support thinking emit `thinking` blocks / `thinking_delta` by default; explicit thinking disablement or `-nothinking` models suppress them
- `signature_delta` is not emitted (DeepSeek does not provide verifiable thinking signatures)
- In `tools` mode, the stream avoids leaking raw tool JSON and does not force `input_json_delta`
### `POST /anthropic/v1/messages/count_tokens`
**Request**:
```json
{
"model": "claude-sonnet-4-6",
"messages": [
{"role": "user", "content": "Hello"}
]
}
```
**Response**:
```json
{
"input_tokens": 5
}
```
---
## Gemini-Compatible API
Supported paths:
- `/v1beta/models/{model}:generateContent`
- `/v1beta/models/{model}:streamGenerateContent`
- `/v1/models/{model}:generateContent` (compat path)
- `/v1/models/{model}:streamGenerateContent` (compat path)
Authentication is the same as other business routes (`Authorization: Bearer <token>` or `x-api-key`).
Implementation-wise this path is unified on the OpenAI Chat Completions parse-and-translate pipeline to avoid maintaining divergent parsing chains.
### `POST /v1beta/models/{model}:generateContent`
Request body accepts Gemini-style `contents` / `tools`. Model names can use aliases and are mapped to DeepSeek models; a `-nothinking` suffix in the path model name resolves to the matching forced no-thinking model. Thinking and search precedence is identical to the OpenAI endpoints; see [Thinking and search resolution](#thinking-and-search-resolution). If `tools.enabled=false` (the default), the `tools` in the request is ignored and the response never contains `tool_calls` (a `functionCall` part on the Gemini side).
Response uses Gemini-compatible fields, including:
- `candidates[].content.parts[].text`
- `candidates[].content.parts[].thought=true` for thinking output
- `candidates[].content.parts[].functionCall` (when tool call is produced)
- `usageMetadata` (`promptTokenCount` / `candidatesTokenCount` / `totalTokenCount`)
### `POST /v1beta/models/{model}:streamGenerateContent`
Returns SSE (`text/event-stream`), each chunk as `data: <json>`:
- regular text: incremental text chunks
- thinking: incremental chunks with `parts[].thought=true`
- `tools` mode: buffered and emitted as `functionCall` at finalize phase
- final chunk: includes `finishReason: "STOP"` and `usageMetadata`
- Token counting prefers pass-through from upstream DeepSeek SSE (`accumulated_token_usage` / `token_usage`), and only falls back to local estimation when upstream usage is absent
---
## Ollama API
- `POST /api/show` request body: `{"model":"<model-id>"}`.
- `GET /api/tags` lists only the four flash model IDs (`deepseek-v4-flash`, `deepseek-v4-flash-search`, plus their `-nothinking` variants), matching `/v1/models`. Retired pro / vision names no longer appear, but they are still accepted as a `model` input.
- Response uses lowercase `id` (not `ID`) and includes `capabilities` for Ollama-style clients and strict schemas.
Example response:
```json
{
"id": "deepseek-v4-flash",
"capabilities": ["tools", "thinking"]
}
```
## Admin API
### `POST /admin/login`
Public endpoint.
**Request**:
```json
{
"admin_key": "admin",
"expire_hours": 24
}
```
`expire_hours` is optional, default `24`.
**Response**:
```json
{
"success": true,
"token": "<jwt>",
"expires_in": 86400
}
```
### `GET /admin/verify`
Requires JWT: `Authorization: Bearer <jwt>`
**Response**:
```json
{
"valid": true,
"expires_at": 1738400000,
"remaining_seconds": 72000
}
```
### `GET /admin/vercel/config`
Returns Vercel preconfiguration status. Environment variables are preferred, then the saved `vercel` config block is used as a fallback.
```json
{
"has_token": true,
"token_preview": "vc****en",
"token_source": "config",
"project_id": "prj_xxx",
"team_id": null
}
```
### `GET /admin/config`
Returns sanitized config, including both `keys` and `api_keys`.
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "Primary", "remark": "Production"},
{"key": "k2", "name": "Backup", "remark": "Load test"}
],
"env_backed": false,
"env_source_present": true,
"env_writeback_enabled": true,
"config_path": "/data/config.json",
"vercel": {
"has_token": true,
"token_preview": "vc****en",
"project_id": "prj_xxx",
"team_id": ""
},
"accounts": [
{
"identifier": "user@example.com",
"email": "user@example.com",
"mobile": "",
"has_password": true,
"has_token": true,
"token_preview": "abcde..."
}
],
"model_aliases": {
"claude-sonnet-4-6": "deepseek-v4-flash",
"claude-opus-4-6": "deepseek-v4-flash"
}
}
```
### `POST /admin/config`
Only updates `keys`, `api_keys`, `accounts`, and `model_aliases`.
If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback.
**Request**:
```json
{
"keys": ["k1", "k2"],
"api_keys": [
{"key": "k1", "name": "Primary", "remark": "Production"},
{"key": "k2", "name": "Backup", "remark": "Load test"}
],
"accounts": [
{"email": "user@example.com", "password": "pwd", "token": ""}
],
"model_aliases": {
"claude-sonnet-4-6": "deepseek-v4-flash",
"claude-opus-4-6": "deepseek-v4-flash"
}
}
```
### `api_keys[].thinking_injection` (per-key override for thinking-format injection)
Every `api_keys` entry may carry `thinking_injection` to give that single API key its own injection prompt; the legacy `keys` form does not support the field.
```json
{
"api_keys": [
{
"key": "k1",
"name": "Primary",
"remark": "Production",
"thinking_injection": {
"custom": true,
"enabled": true,
"prompt": "List the known constraints before answering, then give the conclusion."
}
},
{"key": "k2", "name": "Backup"}
]
}
```
| Field | Notes |
| --- | --- |
| `custom` | Defaults to `false`. When `false` (or the whole block is omitted) the key fully follows the global `thinking_injection` |
| `enabled` | Only used when `custom=true`; omitting it means "keep following the global switch" |
| `prompt` | Only used when `custom=true`; empty or whitespace-only means "keep following the global prompt" |
For the global switch and default prompt see `thinking_injection` under [`GET /admin/settings`](#get-adminsettings); for the injection position and the built-in default prompt text see [docs/prompt-compatibility.md](docs/prompt-compatibility.md).
### `api_keys[].prompt_profile` (per-key prompt profile override)
Every `api_keys` entry may carry `prompt_profile` to pick a preset or body for the prompt-profile layer, next to `thinking_injection` and `skills`; the legacy `keys` form does not support the field. The field currently takes effect through the config file / import-export only — the admin panel exposes the global section.
```json
{
"api_keys": [
{
"key": "k1",
"name": "Writing key",
"prompt_profile": {"custom": true, "enabled": true, "name": "writing"}
},
{"key": "k2", "name": "Default key"}
]
}
```
| Field | Notes |
| --- | --- |
| `custom` | Defaults to `false`; when `false` or omitted the key fully follows the global `prompt_profile` |
| `enabled` | Only used when `custom=true`; `false` means this key injects no profile |
| `name` | Only used when `custom=true`; `default` / `writing`, overrides the global preset when non-empty |
| `prompt` | Only used when `custom=true`; overrides the preset body when non-empty (max 20000 characters) |
When the global `prompt_profile.enabled` is `false`, none of the above applies to any key.
### `prompt_style` (built-in prompt style, global)
A top-level string. It only switches the built-in texts; anything you configure yourself is unaffected.
```json
{"prompt_style": "legacy"}
```
| Value | Behaviour |
| --- | --- |
| `v2` (default, empty string also means this) | After a history split the continuation prompt carries an "execution requirements" block (honour earlier requirements item by item) and the built-in execution contract (thinking injection) is injected |
| `legacy` | Continuation goes back to the short pre-`ce5a019` wording and the built-in contract is **not** injected; a `thinking_injection.prompt` you configured explicitly is still injected |
An unknown value never breaks config loading: it falls back to `v2` with a warning. `PUT /admin/settings` rejects unknown values with 400.
### `model_profiles` (custom model list, global)
Expose several public model names on top of the same flash base model, each carrying its own prompt. Whichever name the client sends decides the prompt; entries never affect each other.
```json
{
"model_profiles": [
{
"id": "deepseek-writer",
"base": "deepseek-v4-flash",
"name": "Writing",
"remark": "Long-form and rewrites",
"prompt": "## Writing rules\n…"
}
]
}
```
| Field | Notes |
| --- | --- |
| `id` | Required public model name. Charset `[A-Za-z0-9._-]`, 1-64 characters, must not collide with a built-in model name (e.g. `deepseek-v4-flash`), unique across entries (case-insensitive) |
| `base` | Required base model: `deepseek-v4-flash` / `-search` / `-nothinking` / `-search-nothinking` |
| `name` / `remark` | Optional, only for readability in the panel and in exports |
| `prompt` | Optional prompt that applies to this model only, max 20000 characters; empty adds no extra spec |
Resolution and injection:
- Model resolution prefers custom entries over built-in aliases: a matching `id` calls upstream with its `base` (`/v1/models`, `/v1/models/{id}`, `/v1/chat/completions`, Claude and Gemini entries all share the same resolver).
- `/v1/models` lists the custom models too, with `owned_by` = `ds2api`.
- The prompt is injected at the same point as `prompt_profile` (after history splitting, before the skill layer) and the two are **mutually exclusive**: a non-empty model `prompt` wins, otherwise the global `prompt_profile` applies.
- At most 50 entries. See [`GET` / `PUT /admin/model_profiles`](#get--put-adminmodel_profiles).
### `api_keys[].skills` (per-key skill injection)
Every `api_keys` entry may carry `skills` to choose which skills that single API key injects. It sits next to `thinking_injection`; the legacy `keys` form does not support the field.
```json
{
"api_keys": [
{"key": "k1", "name": "Primary", "skills": {"custom": true, "enabled": true, "ids": ["sql-report"]}},
{"key": "k2", "name": "Backup"}
]
}
```
| Field | Notes |
| --- | --- |
| `custom` | Defaults to `false`; when `false` or omitted the key uses every entry in `skills.items` |
| `enabled` | Only used when `custom=true`; `false` means this key injects nothing |
| `ids` | Only used when `custom=true`; non-empty means only the matching skills, empty means this key injects nothing |
| `custom` | `enabled` | `ids` | Result |
| --- | --- | --- | --- |
| omitted or `false` | any | any | Uses all `items` |
| `true` | `false` | any | Nothing injected |
| `true` | omitted or `true` | non-empty | Only the matching `items` |
| `true` | omitted or `true` | empty | Nothing injected |
When the global `skills.enabled=false`, the whole table is moot and no key injects anything. For skill bodies and the admin endpoint see [`GET` / `PUT /admin/skills`](#get--put-adminskills).
### `GET /admin/settings`
Reads runtime settings and status, including:
- `success`
- `admin` (`has_password_hash`, `jwt_expire_hours`, `jwt_valid_after_unix`, `default_password_warning`)
- `runtime` (`account_max_inflight`, `account_max_queue`, `global_max_inflight`, `token_refresh_interval_hours`)
- `runtime.token_refresh_interval_hours` is a session health-check interval: on expiry the gateway performs a password-free check (`GET /api/v0/users/current`) and only falls back to a password login when the server explicitly rejects the session
- `config_snapshot`: raw config values (`admin` / `runtime` / `responses` / `embeddings` / `auto_delete` / `current_input_file` / `thinking_injection` / `prompt_profile` / `model_aliases`) before env overrides
- `responses` / `embeddings`
- `auto_delete` (`mode`: `none` / `single` / `all`; legacy `sessions=true` is still treated as `all`)
- `current_input_file` (`enabled` defaults to `true`, plus `min_chars`)
- `thinking_injection` (`enabled` defaults to `true`, plus `prompt` and `default_prompt`); an empty `prompt` uses the built-in default, and a single key can override it through `api_keys[].thinking_injection`
- `prompt_profile` (`enabled` defaults to `false`, `enabled_default` always `false`, plus `name`, `prompt` and `presets`); `presets` is the built-in `[{name, body}]` list, so the panel can show the body a blank input falls back to
- `prompt_style` (`mode`: effective style `v2` / `legacy`; `modes`: allowed values; `default`: `v2`; `value_raw`: the value stored in config)
- `model_profiles` (`count`, `max_count`, `base_models`, `max_id_chars`, `max_prompt_chars`); the entries themselves are managed through `/admin/model_profiles`
- `model_aliases`
- `tools` (`enabled`: master switch for tool calling, defaults to `false`; `default_enabled`: always `false`, used by the panel to show the default)
- `env_backed`, `needs_vercel_sync`
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
### `PUT /admin/settings`
Hot-updates runtime settings. Supported fields:
- `admin.jwt_expire_hours`
- `runtime.account_max_inflight` / `runtime.account_max_queue` / `runtime.global_max_inflight` / `runtime.token_refresh_interval_hours`
- `responses.store_ttl_seconds`
- `embeddings.provider`
- `auto_delete.mode`
- `current_input_file.enabled` / `current_input_file.min_chars`
- `thinking_injection.enabled` / `thinking_injection.prompt`
- `prompt_profile.enabled` / `prompt_profile.name` / `prompt_profile.prompt`
- `prompt_style`
- `tools.enabled`
- `model_aliases`
- `toolcall` policy is fixed and is no longer writable through settings
`prompt_profile` is the new prompt-profile layer and is off by default; `name` accepts only `default` / `writing` (anything else returns 400) and `prompt` is capped at 20000 characters (over the cap returns 400). Sending only some fields leaves the rest untouched:
```json
{"prompt_profile": {"enabled": true, "name": "writing"}}
```
For the injection position, layering order and preset bodies see [docs/prompt-compatibility.md](docs/prompt-compatibility.md) and [docs/prompt-design.md](docs/prompt-design.md).
`prompt_style` is a top-level string (not an object); it accepts `v2` / `legacy` / an empty string (empty means back to v2):
```json
{"prompt_style": "legacy"}
```
The custom model list does not go through settings — use `/admin/model_profiles` below, which replaces the whole list.
`tools.enabled` only changes the field you actually send; the existing semantics of every other setting stay the same:
```json
{"tools": {"enabled": true}}
```
When it is off (the default), requests that carry `tools` are handled as plain chat. DSML tool calling can trip upstream risk control and get an account banned, so enable it deliberately; the risk notes live in [docs/prompt-compatibility.md](docs/prompt-compatibility.md).
### `POST /admin/settings/password`
Updates admin password and invalidates existing JWTs.
Request example:
```json
{"new_password":"your-new-password"}
```
It also accepts `{"password":"your-new-password"}`.
### `GET` / `PUT /admin/skills`
A plain-text skill library, experimental and off by default (`skills.enabled=false`). The server concatenates skill bodies into the prompt, so the model follows a fixed routine without ever emitting a tool call.
- Global config: `skills.enabled` (defaults to `false`), `skills.max_chars` (defaults to `8000`), `skills.items`
- Per-key override: `api_keys[].skills`, semantics in `api_keys[].skills` above
- Injection shape, injection order, and "why plain text instead of tool calling": the skills section of [docs/prompt-compatibility.md](docs/prompt-compatibility.md) (Chinese)
**GET response**:
```json
{
"enabled": false,
"max_chars": 8000,
"items": [
{
"id": "sql-report",
"name": "SQL report",
"description": "Write SQL against a fixed metric definition",
"body": "# Skill body markdown\n..."
}
]
}
```
**PUT request**: same shape as GET; it replaces the whole library, no incremental merge.
```json
{
"enabled": true,
"max_chars": 8000,
"items": [
{
"id": "sql-report",
"name": "SQL report",
"description": "Write SQL against a fixed metric definition",
"body": "# Reporting rules\nAggregate by day first, then split by channel."
}
]
}
```
**PUT success response**:
```json
{"success": true, "count": 1}
```
**Validation rules** (any violation returns `400` with `{"detail": "..."}`):
| Field | Rule |
| --- | --- |
| `items` | At most 50 entries |
| `id` | Required, unique, character set `[a-z0-9_-]` |
| `body` | Required; at most 100000 characters per entry and 500000 characters in total |
| `max_chars` | Defaults to `8000`; truncates by character (rune) and appends `(技能内容已截断)` when truncated; `0` means unlimited |
Validation runs on write and is independent of `enabled`, so you can author and fix the content with the switch still off, then turn it on. Over-limit bodies, duplicate ids, and invalid ids are never silently fixed.
### `GET` / `PUT /admin/model_profiles`
The custom model list (public name + base model + per-model prompt). Like the skill library it is a standalone endpoint with whole-list replacement semantics: the `items` you send become the final list, and an empty array clears it.
**GET response**:
```json
{
"items": [
{"id": "deepseek-writer", "base": "deepseek-v4-flash", "name": "Writing", "remark": "long-form", "prompt": "## Writing rules\n…"}
],
"base_models": ["deepseek-v4-flash", "deepseek-v4-flash-search", "deepseek-v4-flash-nothinking", "deepseek-v4-flash-search-nothinking"],
"max_count": 50,
"max_id_chars": 64,
"max_prompt_chars": 20000
}
```
**PUT request**:
```json
{"items": [{"id": "deepseek-writer", "base": "deepseek-v4-flash", "prompt": "…"}]}
```
**Validation** (any failure returns `400` with `{"detail": "..."}`):
| Field | Rule |
| --- | --- |
| `items` | At most 50 entries |
| `id` | Required, unique (case-insensitive), charset `[A-Za-z0-9._-]`, 1-64 characters, must not shadow a built-in model name |
| `base` | Required, one of the four `deepseek-v4-flash` names |
| `prompt` | Optional, ≤ 20000 characters |
Saving takes effect immediately: `/v1/models` and model-name resolution pick up the new entries without a restart.
### `POST /admin/config/import`
Imports full config with:
- `mode=merge` (default)
- `mode=replace`
The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`.
Query params `?mode=merge` / `?mode=replace` are also supported.
`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `auto_delete` and `current_input_file` via `/admin/settings` or the config file; legacy `compat` and `toolcall` fields are ignored.
> Note: `merge` mode does not update `auto_delete` or `current_input_file`.
### `GET /admin/config/export`
Exports full config in three forms: `config`, `json`, and `base64`.
### `POST /admin/keys`
```json
{"key": "new-api-key", "name": "Primary", "remark": "Production"}
```
**Response**: `{"success": true, "total_keys": 3}`
### `PUT /admin/keys/{key}`
Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed.
```json
{"name": "Backup", "remark": "Load test"}
```
**Response**: `{"success": true, "total_keys": 3}`
### `DELETE /admin/keys/{key}`
**Response**: `{"success": true, "total_keys": 2}`
### `GET /admin/proxies`
Lists proxy configs (password is never returned; use `has_password` as a marker).
### `POST /admin/proxies`
Adds a proxy. Request accepts `id` (optional; auto-generated when omitted), `name`, `type` (`http` / `socks5`), `host`, `port`, `username`, `password`.
### `PUT /admin/proxies/{proxyID}`
Updates a proxy. If `password` is an empty string, the existing secret is preserved.
### `DELETE /admin/proxies/{proxyID}`
Deletes a proxy and automatically clears `proxy_id` on all accounts that reference it.
### `POST /admin/proxies/test`
Tests proxy connectivity: provide `proxy_id` to test a saved proxy; omit it to run a one-off test using proxy fields in the request body.
### `GET /admin/accounts`
**Query params**:
| Param | Default | Range |
| --- | --- | --- |
| `page` | `1` | ≥ 1 |
| `page_size` | `10` | 1–5000 |
| `q` | empty | Filter by identifier / email / mobile |
**Response**:
```json
{
"items": [
{
"identifier": "user@example.com",
"email": "user@example.com",
"mobile": "",
"has_password": true,
"has_token": true,
"token_preview": "abc...",
"test_status": "ok"
}
],
"total": 25,
"page": 1,
"page_size": 10,
"total_pages": 3
}
```
Returned items also include `test_status`, usually `ok` or `failed`.
### `POST /admin/accounts`
```json
{"email": "user@example.com", "password": "pwd"}
```
**Response**: `{"success": true, "total_accounts": 6}`
### `PUT /admin/accounts/{identifier}`
Updates the `name` / `remark` of the specified account. The path `identifier` can be email or mobile and cannot be changed.
```json
{"name": "Primary account", "remark": "Shared with the team"}
```
**Response**: `{"success": true, "total_accounts": 6}`
### `DELETE /admin/accounts/{identifier}`
`identifier` can be email, mobile, or the synthetic id for token-only accounts (`token:<hash>`).
**Response**: `{"success": true, "total_accounts": 5}`
### `PUT /admin/accounts/{identifier}/proxy`
Updates proxy binding for a specific account.
- Request body: `{"proxy_id":"..."}`.
- Use empty `proxy_id` to unbind proxy.
- `identifier` supports email / mobile / token-only synthetic id.
### `GET /admin/queue/status`
```json
{
"available": 3,
"in_use": 1,
"total": 4,
"available_accounts": ["a@example.com"],
"in_use_accounts": ["b@example.com"],
"max_inflight_per_account": 2,
"global_max_inflight": 8,
"recommended_concurrency": 8,
"waiting": 0,
"max_queue_size": 8
}
```
| Field | Description |
| --- | --- |
| `available` | Accounts that still have spare inflight capacity |
| `in_use` | Number of occupied in-flight slots |
| `total` | Total accounts |
| `available_accounts` | List of account IDs with remaining inflight capacity |
| `in_use_accounts` | List of account IDs currently in use |
| `max_inflight_per_account` | Per-account inflight limit |
| `global_max_inflight` | Global inflight limit |
| `recommended_concurrency` | Suggested concurrency (`total × max_inflight_per_account`) |
| `waiting` | Number of queued requests currently waiting |
| `max_queue_size` | Waiting queue limit |
### `POST /admin/accounts/test`
| Field | Required | Notes |
| --- | --- | --- |
| `identifier` | ✅ | email / mobile / token-only synthetic id |
| `model` | ❌ | default `deepseek-v4-flash` |
| `message` | ❌ | if empty, only session creation is tested |
**Response**:
```json
{
"account": "user@example.com",
"success": true,
"response_time": 1240,
"message": "API test successful (session creation only)",
"model": "deepseek-v4-flash",
"session_count": 0,
"config_writable": true,
"config_warning": ""
}
```
If a `message` is provided, `thinking` may also be included when the upstream response carries reasoning text.
When the configured file path is not writable (for example, read-only `/app/config.json` inside some containers), login/session testing still proceeds; `config_warning` is returned to indicate token persistence failed and the token is memory-only until restart.
### `POST /admin/accounts/test-all`
Optional request field: `model`.
```json
{
"total": 5,
"success": 4,
"failed": 1,
"results": [...]
}
```
The internal concurrency limit is currently fixed at 5.
### `POST /admin/accounts/sessions/delete-all`
Deletes all DeepSeek sessions for a specific account. Request example:
```json
{"identifier":"user@example.com"}
```
Response:
```json
{"success": true, "message": "删除成功"}
```
If the account is missing or deletion fails, `success` becomes `false` and `message` contains the error.
The current handler returns the Chinese literal `删除成功` on success.
### `POST /admin/import`
Batch import keys and accounts.
**Request**:
```json
{
"keys": ["k1", "k2"],
"accounts": [
{"email": "user@example.com", "password": "pwd", "token": ""}
]
}
```
**Response**:
```json
{
"success": true,
"imported_keys": 2,
"imported_accounts": 1
}
```
### `POST /admin/test`
Test API availability through the service itself.
| Field | Required | Default |
| --- | --- | --- |
| `model` | ❌ | `deepseek-v4-flash` |
| `message` | ❌ | `你好` |
| `api_key` | ❌ | First key in config |
**Response**:
```json
{
"success": true,
"status_code": 200,
"response": {"id": "..."}
}
```
### `POST /admin/dev/raw-samples/capture`
Internally issues one `/v1/chat/completions` request through the service, then persists the request metadata and raw upstream SSE into `tests/raw_stream_samples/<sample-id>/`.
Common request fields:
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `message` | No | `你好` | Convenience single-turn user message |
| `messages` | No | Auto-derived from `message` | OpenAI-style message array |
| `model` | No | `deepseek-v4-flash` | Target model |
| `stream` | No | `true` | Recommended to keep streaming enabled so raw SSE is recorded |
| `api_key` | No | First configured key | Business API key to use |
| `sample_id` | No | Auto-generated | Sample directory name |
On success, the response headers include:
- `X-Ds2-Sample-Id`
- `X-Ds2-Sample-Dir`
- `X-Ds2-Sample-Meta`
- `X-Ds2-Sample-Upstream`
If the request itself succeeds but the process did not record a new upstream capture, the endpoint returns:
```json
{"detail":"no upstream capture was recorded"}
```
### `GET /admin/dev/raw-samples/query`
Searches the current process's in-memory capture entries and groups `completion + continue` rounds by `chat_session_id`.
**Query parameters**:
| Param | Default | Notes |
| --- | --- | --- |
| `q` | empty | Fuzzy match against request/response text |
| `limit` | `20` | Max number of chains returned |
**Response fields** include:
- `items[].chain_key`
- `items[].capture_ids`
- `items[].round_count`
- `items[].initial_label`
- `items[].request_preview`
- `items[].response_preview`
### `POST /admin/dev/raw-samples/save`
Persists one selected in-memory capture chain into `tests/raw_stream_samples/<sample-id>/`.
Any one of these selectors is accepted:
```json
{"chain_key":"session:xxxx","sample_id":"tmp-from-memory"}
```
```json
{"capture_id":"cap_xxx","sample_id":"tmp-from-memory"}
```
```json
{"query":"Guangzhou weather","sample_id":"tmp-from-memory"}
```
The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path`.
### `POST /admin/vercel/sync`
| Field | Required | Notes |
| --- | --- | --- |
| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env, then saved config |
| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID`, then saved config |
| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID`, then saved config |
| `auto_validate` | ❌ | Default `true` |
| `save_credentials` | ❌ | Default `true`; saves explicitly supplied Vercel credentials for the next sync |
| `config_override` | ❌ | Provide a full config object to sync instead of the runtime config |
**Success response**:
```json
{
"success": true,
"validated_accounts": 3,
"message": "Config synced, redeploying...",
"deployment_url": "https://..."
}
```
Or manual deploy required:
```json
{
"success": true,
"validated_accounts": 3,
"message": "Config synced to Vercel, please trigger redeploy manually",
"manual_deploy_required": true
}
```
Failed account checks are returned in `failed_accounts`, and any saved Vercel credentials are returned in `saved_credentials`.
### `GET /admin/vercel/status`
```json
{
"synced": true,
"last_sync_time": 1738400000,
"has_synced_before": true,
"env_backed": false,
"config_hash": "....",
"last_synced_hash": "....",
"draft_hash": "....",
"draft_differs": false
}
```
`POST /admin/vercel/status` can also accept `config_override` to compare a draft config against the current synced config.
### `GET /admin/export`
```json
{
"json": "{...}",
"base64": "ey4uLn0="
}
```
This is the same payload as `GET /admin/config/export`, just with a shorter path.
### `GET /admin/version`
Checks the current build version and the latest GitHub Release:
```json
{
"success": true,
"current_version": "3.0.0",
"current_tag": "v3.0.0",
"source": "file:VERSION",
"checked_at": "2026-03-29T00:00:00Z",
"latest_tag": "v3.0.0",
"latest_version": "3.0.0",
"release_url": "https://github.com/CJackHwang/ds2api/releases/tag/v3.0.0",
"published_at": "2026-03-28T12:00:00Z",
"has_update": false
}
```
If GitHub API access fails, the response includes `check_error` while still returning HTTP 200.
### `GET /admin/dev/captures`
Reads local packet-capture status and recent entries (Admin auth required):
- `enabled`
- `limit`
- `max_body_bytes`
- `items`
### `DELETE /admin/dev/captures`
Clears packet-capture entries:
```json
{"success":true,"detail":"capture logs cleared"}
```
### `GET /admin/chat-history`
Reads server-side conversation history with filtering and paging.
**Query parameters**:
| Parameter | Default | Matching rule |
| --- | --- | --- |
| `caller` | empty | Exact match on `caller_id` (use `callers[].caller_id` from the response, not the key name) |
| `status` | empty | Case-insensitive exact match: `streaming` / `success` / `error` / `stopped` |
| `surface` | empty | Case-insensitive exact match, e.g. `openai.chat_completions`, `openai.responses`, `claude.messages`, `gemini.generate_content` |
| `q` | empty | Case-insensitive substring match over `user_input` / `preview` / `model` / `caller_label` / `error` |
| `offset` | `0` | Paging offset; `offset` and `limit` both `0` means "no paging" |
| `limit` | `0` | Page size; `0` means "no limit" (the settings retention limit is not used as a page size) |
Invalid or negative `offset` / `limit` values are treated as `0`.
**Response**:
```json
{
"version": 2,
"limit": 20,
"revision": 128,
"total": 137,
"callers": [
{"caller_id": "caller:9f2c1a2b3c4d5e6f", "label": "Primary", "configured": true, "count": 96},
{"caller_id": "caller:41ab0c9d8e7f6a5b", "label": "Backup", "configured": true, "count": 0}
],
"items": [
{
"id": "chat_20260418_093012_ab12",
"status": "success",
"caller_id": "caller:9f2c1a2b3c4d5e6f",
"caller_label": "Primary",
"account_id": "user@example.com",
"surface": "openai.chat_completions",
"model": "deepseek-v4-flash",
"stream": true,
"user_input": "today's news",
"preview": "……",
"status_code": 200,
"elapsed_ms": 4210,
"finish_reason": "stop",
"created_at": 1777000000000,
"updated_at": 1777000004000,
"detail_revision": 3
}
],
"path": "/data/chat_history.json"
}
```
- `total` is the filtered count, `items` is the current page; without any filter or paging parameter `items` still holds all summaries.
- `caller_label` is a readable label for the calling API key (name / remark / masked value); keys without a remark fall back to the masked value so the panel never shows a bare hash.
- `callers` aggregates the `caller_id`s found in records and adds keys that are configured but have no records yet (`count` is `0`, `configured=true`), sorted by `count` descending and `label` ascending.
- Timestamp fields (`created_at` / `updated_at` / `completed_at`) are milliseconds.
- `If-None-Match` + `ETag` revalidation (`304`) only applies when **no filter or paging parameter** is set, because a filtered response body changes with the query.
### `GET /admin/chat-history/{id}`
Reads one full record as `{"item": {...}}`. Besides the summary fields it carries `messages`, `history_text` (the full conversation transcript uploaded by the history split), `final_prompt`, `reasoning_content`, `content`, `error`, `usage`, and `file_infos` (uploaded file IDs, audit result, source). `ETag` / `304` is supported here too.
### `DELETE /admin/chat-history`
Clears every server-side record and returns `{"success": true}`.
### `DELETE /admin/chat-history/{id}`
Deletes one record and returns `{"success": true}`; a missing record returns `404`.
### `PUT /admin/chat-history/settings`
Updates the retention limit. `limit` accepts any integer in `0`..`500`; there are no fixed tiers anymore.
```json
{"limit": 120}
```
| Value | Behavior |
| --- | --- |
| `0` | Recording is off: no new records are written |
| `1`..`500` | Keep the newest N records and evict the rest by creation time |
| Anything else | `400` with `detail` = `unsupported chat history limit: <n>` |
The response carries `success`, `limit`, `revision`, and the trimmed `items`.
### `GET /admin/chat-history/export`
Exports records with the same filter and paging parameters as the list endpoint (`caller` / `status` / `surface` / `q` / `offset` / `limit`); the response is a file download.
| Parameter | Notes |
| --- | --- |
| `format=json` (default) | JSON attachment shaped as `{exported_at, count, filters, items}`; `exported_at` is RFC3339, `filters` echoes the applied filters, and `items` holds full records including large fields such as `messages` / `final_prompt` / `history_text` |
| `format=markdown` | Markdown attachment titled `# ds2api 响应记录导出`, one `## N. <record id>` section per record, with time, surface, model, account, caller, status, elapsed time, finish reason, plus `UserInput` / `FinalPrompt` / `ReasoningContent` / `Content` / `Error` code blocks |
| Any other value | `400` with `detail` = `unsupported export format: <format>` |
The response also sets `Content-Disposition: attachment` (filename `ds2api-logs-<YYYYMMDD-HHMMSS>.json|.md`) and `Cache-Control: no-store`. If a record is deleted mid-export it is skipped, and `count` reflects what was actually exported.
---
## Error Payloads
Compatible routes (`/v1/*`, `/anthropic/*`) use the same error envelope:
```json
{
"error": {
"message": "...",
"type": "invalid_request_error",
"code": "invalid_request",
"param": null
}
}
```
Admin routes keep `{"detail":"..."}`.
Gemini routes use Google-style errors:
```json
{
"error": {
"code": 400,
"message": "invalid json",
"status": "INVALID_ARGUMENT"
}
}
```
Clients should handle HTTP status code plus `error` / `detail` fields.
**Common status codes**:
| Code | Meaning |
| --- | --- |
| `401` | Authentication failed (invalid key/token, or expired admin JWT) |
| `429` | Too many requests (exceeded inflight + queue capacity, or upstream thinking-only output with no visible answer; managed-account mode first tries one alternate-account fresh retry; current responses do not include `Retry-After`) |
| `503` | Model unavailable or upstream error |
---
## cURL Examples
### OpenAI Non-Stream
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
}'
```
### OpenAI Stream
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "Explain quantum entanglement"}],
"stream": true
}'
```
### OpenAI Responses (Stream)
```bash
curl http://localhost:5001/v1/responses \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5-codex",
"input": "Write a hello world in golang",
"stream": true
}'
```
### OpenAI Embeddings
```bash
curl http://localhost:5001/v1/embeddings \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"input": ["first text", "second text"]
}'
```
### OpenAI with Search
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash-search",
"messages": [{"role": "user", "content": "Latest news today"}],
"stream": true
}'
```
### OpenAI Tool Calling
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "What is the weather in Beijing?"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
}
}
]
}'
```
### Gemini Non-Stream
```bash
curl "http://localhost:5001/v1beta/models/gemini-2.5-pro:generateContent" \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"contents": [
{
"role": "user",
"parts": [{"text": "Introduce Go in three sentences"}]
}
]
}'
```
### Gemini Stream
```bash
curl "http://localhost:5001/v1beta/models/gemini-2.5-flash:streamGenerateContent" \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"contents": [
{
"role": "user",
"parts": [{"text": "Write a short summary"}]
}
]
}'
```
### Claude Non-Stream
```bash
curl http://localhost:5001/anthropic/v1/messages \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
}'
```
### Claude Stream
```bash
curl http://localhost:5001/anthropic/v1/messages \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-opus-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Explain relativity"}],
"stream": true
}'
```
### Admin Login
```bash
curl http://localhost:5001/admin/login \
-H "Content-Type: application/json" \
-d '{"admin_key": "admin"}'
```
### Pin Specific Account
```bash
curl http://localhost:5001/v1/chat/completions \
-H "Authorization: Bearer your-api-key" \
-H "X-Ds2-Target-Account: user@example.com" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": "Hello"}]
}'
```