ds2api / API.en.md
luckfun233
feat(prompt): 新增 prompt_style 风格档位与自定义模型列表
fcdd4aa
|
Raw History Blame
74.4 kB

DS2API API Reference

Language: 中文 | English

This document describes the actual behavior of the current Go codebase.

Docs: Overview / Architecture / Deployment / Testing / Prompt compatibility flow


Table of Contents


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, 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:

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
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).


Health Endpoints

GET /healthz

{"status": "ok"}

GET /readyz

{"status": "ready"}

OpenAI-Compatible API

GET /v1/models

No auth required. Returns the currently supported DeepSeek native model list.

Response:

{
  "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:

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.

Non-Stream Response

{
  "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].

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 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.

{
  "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:

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:

{
  "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:

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. 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

{
  "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.

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:

{
  "model": "claude-sonnet-4-6",
  "messages": [
    {"role": "user", "content": "Hello"}
  ]
}

Response:

{
  "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. 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:

{
  "id": "deepseek-v4-flash",
  "capabilities": ["tools", "thinking"]
}

Admin API

POST /admin/login

Public endpoint.

Request:

{
  "admin_key": "admin",
  "expire_hours": 24
}

expire_hours is optional, default 24.

Response:

{
  "success": true,
  "token": "<jwt>",
  "expires_in": 86400
}

GET /admin/verify

Requires JWT: Authorization: Bearer <jwt>

Response:

{
  "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.

{
  "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.

{
  "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:

{
  "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.

{
  "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; for the injection position and the built-in default prompt text see 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.

{
  "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.

{"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.

{
  "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.

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.

{
  "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 /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:

{"prompt_profile": {"enabled": true, "name": "writing"}}

For the injection position, layering order and preset bodies see docs/prompt-compatibility.md and 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):

{"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:

{"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.

POST /admin/settings/password

Updates admin password and invalidates existing JWTs.

Request example:

{"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 (Chinese)

GET response:

{
  "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.

{
  "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:

{"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:

{
  "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:

{"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

{"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.

{"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:

{
  "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

{"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.

{"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

{
  "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:

{
  "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.

{
  "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:

{"identifier":"user@example.com"}

Response:

{"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:

{
  "keys": ["k1", "k2"],
  "accounts": [
    {"email": "user@example.com", "password": "pwd", "token": ""}
  ]
}

Response:

{
  "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:

{
  "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:

{"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:

{"chain_key":"session:xxxx","sample_id":"tmp-from-memory"}
{"capture_id":"cap_xxx","sample_id":"tmp-from-memory"}
{"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:

{
  "success": true,
  "validated_accounts": 3,
  "message": "Config synced, redeploying...",
  "deployment_url": "https://..."
}

Or manual deploy required:

{
  "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

{
  "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": "{...}",
  "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:

{
  "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:

{"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:

{
  "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_ids 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.

{"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:

{
  "error": {
    "message": "...",
    "type": "invalid_request_error",
    "code": "invalid_request",
    "param": null
  }
}

Admin routes keep {"detail":"..."}.

Gemini routes use Google-style errors:

{
  "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

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

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)

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

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

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

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

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

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

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

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

curl http://localhost:5001/admin/login \
  -H "Content-Type: application/json" \
  -d '{"admin_key": "admin"}'

Pin Specific Account

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"}]
  }'