diff --git a/.env.example b/.env.example index 57b8648..3b0a6aa 100644 --- a/.env.example +++ b/.env.example @@ -1,74 +1,54 @@ -# DS2API 环境变量配置模板 -# 复制此文件为 .env 并根据需要修改 -# 最后更新:2026-02 +# DS2API environment template (Go runtime) +# Copy this file to .env and adjust values. +# Updated: 2026-02 -# =============================================================== -# 核心配置 -# =============================================================== - -# ----- 服务配置 ----- -# 服务端口(默认 5001) +# --------------------------------------------------------------- +# Runtime +# --------------------------------------------------------------- +# HTTP listen port (default: 5001) PORT=5001 -# 服务监听地址 -HOST=0.0.0.0 - -# 日志级别 (DEBUG, INFO, WARNING, ERROR) +# Log level: DEBUG | INFO | WARN | ERROR LOG_LEVEL=INFO +# --------------------------------------------------------------- +# Admin auth +# --------------------------------------------------------------- +# Admin key for /admin login and protected admin APIs. +# Default is "admin" when unset, but setting it explicitly is recommended. +DS2API_ADMIN_KEY=admin -# =============================================================== -# 数据配置(三选一) -# =============================================================== +# Optional JWT signing secret for admin token. +# Defaults to DS2API_ADMIN_KEY when unset. +# DS2API_JWT_SECRET=change-me -# 方式1: JSON 字符串(适合简单配置) -# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]} +# Optional admin JWT validity in hours (default: 24) +# DS2API_JWT_EXPIRE_HOURS=24 -# 方式2: Base64 编码的 JSON(推荐用于 Vercel,避免特殊字符转义问题) -# 生成方式: echo '{"keys":["your-api-key"],"accounts":[...]}' | base64 -# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0= - -# 方式3: 配置文件路径(本地开发推荐) +# --------------------------------------------------------------- +# Config source (choose one) +# --------------------------------------------------------------- +# Option A: config file path (local/dev recommended) # DS2API_CONFIG_PATH=config.json +# Option B: JSON string +# DS2API_CONFIG_JSON={"keys":["your-api-key"],"accounts":[{"email":"user@example.com","password":"xxx","token":""}]} -# =============================================================== -# 管理界面配置 -# =============================================================== +# Option C: Base64 encoded JSON (recommended for Vercel env var) +# DS2API_CONFIG_JSON=eyJrZXlzIjpbInlvdXItYXBpLWtleSJdLCJhY2NvdW50cyI6W3siZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwicGFzc3dvcmQiOiJ4eHgiLCJ0b2tlbiI6IiJ9XX0= -# Admin API 密钥(Vercel 部署必填!) -# 用于保护 WebUI 管理界面,首次访问 /admin 时需要输入此密钥登录 -DS2API_ADMIN_KEY=your-admin-secret-key - -# JWT Token 过期时间(秒,默认 86400 = 24小时) -# DS2API_SESSION_EXPIRE=86400 - - -# =============================================================== -# Vercel 集成(可选) -# =============================================================== - -# Vercel API Token -# 获取方式: https://vercel.com/account/tokens -# VERCEL_TOKEN=your-vercel-token - -# Vercel Project ID -# 获取方式: Vercel 控制台 -> 项目设置 -> General -> Project ID -# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx - -# Vercel Team ID(个人项目无需填写,团队项目才需要) -# VERCEL_TEAM_ID= - - -# =============================================================== -# 高级配置(可选) -# =============================================================== - -# Tokenizer 目录(留空使用项目根目录) -# DS2API_TOKENIZER_DIR= - -# 模板目录 -# DS2API_TEMPLATES_DIR=templates - -# WASM 文件路径(PoW 计算用) +# --------------------------------------------------------------- +# Paths (optional) +# --------------------------------------------------------------- +# WASM file used for PoW solving # DS2API_WASM_PATH=sha3_wasm_bg.7b9ca65ddd.wasm + +# Built admin static assets directory +# DS2API_STATIC_ADMIN_DIR=static/admin + +# --------------------------------------------------------------- +# Vercel sync integration (optional) +# --------------------------------------------------------------- +# VERCEL_TOKEN=your-vercel-token +# VERCEL_PROJECT_ID=prj_xxxxxxxxxxxx +# VERCEL_TEAM_ID=team_xxxxxxxxxxxx diff --git a/API.en.md b/API.en.md index 6487f45..afa1a3e 100644 --- a/API.en.md +++ b/API.en.md @@ -1,331 +1,285 @@ -# DS2API API Reference +# DS2API API Reference (Go Implementation) Language: [中文](API.md) | [English](API.en.md) -This document describes all DS2API API endpoints. - ---- - -## Table of Contents - -- [Basics](#basics) -- [OpenAI-Compatible API](#openai-compatible-api) - - [List Models](#list-models) - - [Chat Completions](#chat-completions) -- [Claude-Compatible API](#claude-compatible-api) - - [Claude Model List](#claude-model-list) - - [Claude Messages](#claude-messages) - - [Token Counting](#token-counting) -- [Admin API](#admin-api) - - [Login](#login) - - [Configuration](#configuration) - - [Account Management](#account-management) - - [Vercel Sync](#vercel-sync) -- [Error Handling](#error-handling) -- [Examples](#examples) - ---- +This document describes the actual behavior of the current Go codebase. ## Basics -| Item | Description | -|-----|------| -| **Base URL** | `https://your-domain.com` or `http://localhost:5001` | -| **OpenAI auth** | `Authorization: Bearer ` | -| **Claude auth** | `x-api-key: ` | -| **Response format** | JSON | +- Base URL: `http://localhost:5001` or your deployment domain +- Default content type: `application/json` +- Health probes: `GET /healthz`, `GET /readyz` ---- +### Authentication Rules + +Business endpoints (`/v1/*`, `/anthropic/*`) accept either: + +1. `Authorization: Bearer ` +2. `x-api-key: ` (without `Bearer`) + +Admin endpoints: + +- `POST /admin/login` is public +- `GET /admin/verify` requires `Authorization: Bearer ` (JWT only) +- Other protected `/admin/*` endpoints accept: +- `Authorization: Bearer ` +- `Authorization: Bearer ` + +## Route Index + +| Method | Path | Description | +| --- | --- | --- | +| GET | `/healthz` | Liveness probe | +| GET | `/readyz` | Readiness probe | +| GET | `/v1/models` | OpenAI model list | +| POST | `/v1/chat/completions` | OpenAI chat completions | +| GET | `/anthropic/v1/models` | Claude model list | +| POST | `/anthropic/v1/messages` | Claude messages | +| POST | `/anthropic/v1/messages/count_tokens` | Claude token counting | +| POST | `/admin/login` | Admin login | +| GET | `/admin/verify` | Verify admin JWT | +| GET | `/admin/vercel/config` | Read preconfigured Vercel creds | +| GET | `/admin/config` | Read sanitized config | +| POST | `/admin/config` | Update config | +| POST | `/admin/keys` | Add API key | +| DELETE | `/admin/keys/{key}` | Delete API key | +| GET | `/admin/accounts` | Paginated account list | +| POST | `/admin/accounts` | Add account | +| DELETE | `/admin/accounts/{identifier}` | Delete account | +| GET | `/admin/queue/status` | Account queue status | +| POST | `/admin/accounts/test` | Test one account | +| POST | `/admin/accounts/test-all` | Test all accounts | +| POST | `/admin/import` | Batch import keys/accounts | +| POST | `/admin/test` | Test API through current service | +| POST | `/admin/vercel/sync` | Sync config to Vercel | +| GET | `/admin/vercel/status` | Vercel sync status | +| GET | `/admin/export` | Export config JSON/Base64 | + +## Health Endpoints + +### `GET /healthz` + +```json +{"status":"ok"} +``` + +### `GET /readyz` + +```json +{"status":"ready"} +``` ## OpenAI-Compatible API -### List Models +### `GET /v1/models` -```http -GET /v1/models -``` +No auth required. -**Response example**: +Example response: ```json { "object": "list", "data": [ - {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-chat-search", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-reasoner-search", "object": "model", "owned_by": "deepseek"} + {"id": "deepseek-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} ] } ``` ---- +### `POST /v1/chat/completions` -### Chat Completions +Headers: ```http -POST /v1/chat/completions Authorization: Bearer your-api-key Content-Type: application/json ``` -**Parameters**: +Core request fields: -| Parameter | Type | Required | Description | -|-----|------|:----:|------| -| `model` | string | ✅ | Model name (see below) | -| `messages` | array | ✅ | Chat messages | -| `stream` | boolean | ❌ | Stream responses (default `false`) | -| `temperature` | number | ❌ | Temperature (0-2) | -| `max_tokens` | number | ❌ | Max output tokens | -| `tools` | array | ❌ | Tool definitions (Function Calling) | -| `tool_choice` | string | ❌ | Tool selection strategy | +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `model` | string | yes | `deepseek-chat` / `deepseek-reasoner` / `deepseek-chat-search` / `deepseek-reasoner-search` | +| `messages` | array | yes | OpenAI-style messages | +| `stream` | boolean | no | default `false` | +| `tools` | array | no | Function calling schema | +| `temperature`, etc. | any | no | accepted in request; final behavior depends on upstream | -**Supported models**: - -| Model | Reasoning | Search | Notes | -|-----|:--------:|:------:|------| -| `deepseek-chat` | ❌ | ❌ | Standard chat | -| `deepseek-reasoner` | ✅ | ❌ | Reasoning mode with trace | -| `deepseek-chat-search` | ❌ | ✅ | Search enhanced | -| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search | - -**Basic request example**: +Non-stream example: ```json { - "model": "deepseek-chat", - "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Hello"} - ] -} -``` - -**Streaming request example**: - -```json -{ - "model": "deepseek-reasoner-search", - "messages": [ - {"role": "user", "content": "What's in the news today?"} - ], - "stream": true -} -``` - -**Streaming response format** (`stream: true`): - -``` -data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"},"index":0}]} - -data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"reasoning_content":"Let me think..."},"index":0}]} - -data: {"id":"...","object":"chat.completion.chunk","choices":[{"delta":{"content":"Based on search results..."},"index":0}]} - -data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"finish_reason":"stop"}]} - -data: [DONE] -``` - -> **Note**: Reasoning models emit `reasoning_content` with the trace. - -**Non-streaming response format** (`stream: false`): - -```json -{ - "id": "chatcmpl-xxx", + "id": "", "object": "chat.completion", "created": 1738400000, "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "Response text", - "reasoning_content": "Reasoning trace (reasoner only)" - }, - "finish_reason": "stop" - }], + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "final response", + "reasoning_content": "reasoning trace" + }, + "finish_reason": "stop" + } + ], "usage": { "prompt_tokens": 10, - "completion_tokens": 50, - "total_tokens": 60, + "completion_tokens": 20, + "total_tokens": 30, "completion_tokens_details": { - "reasoning_tokens": 20 + "reasoning_tokens": 5 } } } ``` -#### Tool Calling (Function Calling) +### OpenAI Streaming (`stream=true`) -**Request example**: +SSE format: each frame is `data: \n\n`, terminated by `data: [DONE]`. + +- First delta may include `role: assistant` +- Reasoning models emit `delta.reasoning_content` +- Text emits `delta.content` +- Last chunk includes `finish_reason` and usage + +Example: + +```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] +``` + +### Tool Calls (Important) + +When `tools` is present, DS2API injects a tool prompt and parses tool-call payloads. + +- Non-stream: if detected, returns `message.tool_calls`, `finish_reason=tool_calls`, and `message.content=null` +- Stream: to avoid leaking raw tool-call JSON, DS2API buffers text first; if tool call is detected, only structured `delta.tool_calls` is emitted + +Tool-call response example: ```json { - "model": "deepseek-chat", - "messages": [{"role": "user", "content": "What's the weather in Beijing?"}], - "tools": [{ - "type": "function", - "function": { - "name": "get_weather", - "description": "Get the weather for a city", - "parameters": { - "type": "object", - "properties": { - "location": {"type": "string", "description": "City name"} - }, - "required": ["location"] - } + "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" } - }] + ] } ``` -**Response example**: - -```json -{ - "id": "chatcmpl-xxx", - "object": "chat.completion", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_xxx", - "type": "function", - "function": { - "name": "get_weather", - "arguments": "{\"location\": \"Beijing\"}" - } - }] - }, - "finish_reason": "tool_calls" - }] -} -``` - ---- - ## Claude-Compatible API -### Claude Model List +### `GET /anthropic/v1/models` -```http -GET /anthropic/v1/models -``` +No auth required. -**Response example**: +Example response: ```json { "object": "list", "data": [ - {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-sonnet-4-20250514-fast", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-sonnet-4-20250514-slow", "object": "model", "owned_by": "anthropic"} + {"id": "claude-sonnet-4-20250514", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-20250514-fast", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-20250514-slow", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ] } ``` -**Model mapping**: +### `POST /anthropic/v1/messages` -| Claude Model | Actual | Notes | -|------------|--------|------| -| `claude-sonnet-4-20250514` | deepseek-chat | Standard mode | -| `claude-sonnet-4-20250514-fast` | deepseek-chat | Fast mode | -| `claude-sonnet-4-20250514-slow` | deepseek-reasoner | Reasoning mode | - ---- - -### Claude Messages +Headers can be: ```http -POST /anthropic/v1/messages x-api-key: your-api-key Content-Type: application/json anthropic-version: 2023-06-01 ``` -**Parameters**: +Core request fields: -| Parameter | Type | Required | Description | -|-----|------|:----:|------| -| `model` | string | ✅ | Model name | -| `max_tokens` | integer | ✅ | Max output tokens | -| `messages` | array | ✅ | Chat messages | -| `stream` | boolean | ❌ | Stream responses (default `false`) | -| `system` | string | ❌ | System prompt | -| `temperature` | number | ❌ | Temperature | +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `model` | string | yes | `claude-sonnet-4-20250514` / `-fast` / `-slow` | +| `messages` | array | yes | Claude-style messages | +| `max_tokens` | number | no | currently not strictly enforced by upstream bridge | +| `stream` | boolean | no | default `false` | +| `system` | string | no | optional system prompt | +| `tools` | array | no | Claude tool schema | -**Request example**: +Non-stream example: ```json { - "model": "claude-sonnet-4-20250514", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "Hello, please introduce yourself."} - ] -} -``` - -**Non-streaming response**: - -```json -{ - "id": "msg_xxx", + "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "content": [{ - "type": "text", - "text": "Hello! I'm an AI assistant..." - }], "model": "claude-sonnet-4-20250514", + "content": [ + {"type": "text", "text": "response"} + ], "stop_reason": "end_turn", + "stop_sequence": null, "usage": { - "input_tokens": 10, - "output_tokens": 50 + "input_tokens": 12, + "output_tokens": 34 } } ``` -**Streaming response** (SSE): +If tool use is detected, `stop_reason` becomes `tool_use` and `content` contains `tool_use` blocks. -``` -event: message_start -data: {"type":"message_start","message":{"id":"msg_xxx","type":"message","role":"assistant","model":"claude-sonnet-4-20250514"}} +### Claude Streaming (`stream=true`) + +Still SSE, but current implementation writes `data:` lines only (no `event:` lines). Event type is carried in JSON `type`. + +Example: + +```text +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"}} +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}} -event: content_block_stop data: {"type":"content_block_stop","index":0} -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}} -event: message_stop data: {"type":"message_stop"} ``` ---- +### `POST /anthropic/v1/messages/count_tokens` -### Token Counting - -```http -POST /anthropic/v1/messages/count_tokens -x-api-key: your-api-key -Content-Type: application/json -``` - -**Request example**: +Request example: ```json { @@ -336,7 +290,7 @@ Content-Type: application/json } ``` -**Response example**: +Response example: ```json { @@ -344,294 +298,341 @@ Content-Type: application/json } ``` ---- - ## Admin API -All admin endpoints (except login) require `Authorization: Bearer `. +### `POST /admin/login` -### Login - -```http -POST /admin/login -Content-Type: application/json -``` - -**Request body**: +Request: ```json { - "key": "your-admin-key" + "admin_key": "admin", + "expire_hours": 24 } ``` -**Response**: +`expire_hours` is optional, default 24. + +Response: ```json { "success": true, - "token": "jwt-token-string", + "token": "", "expires_in": 86400 } ``` -> Tokens are valid for 24 hours by default. +### `GET /admin/verify` ---- +Header: `Authorization: Bearer ` -### Configuration - -#### Get configuration - -```http -GET /admin/config -Authorization: Bearer -``` - -**Response**: +Response: ```json { - "keys": ["api-key-1", "api-key-2"], + "valid": true, + "expires_at": 1738400000, + "remaining_seconds": 72000 +} +``` + +### `GET /admin/vercel/config` + +```json +{ + "has_token": true, + "project_id": "prj_xxx", + "team_id": null +} +``` + +### `GET /admin/config` + +Sanitized config response: + +```json +{ + "keys": ["k1", "k2"], "accounts": [ { "email": "user@example.com", - "password": "***", - "token": "session-token" + "mobile": "", + "has_password": true, + "has_token": true, + "token_preview": "abcde..." } - ] -} -``` - -#### Update configuration - -```http -POST /admin/config -Authorization: Bearer -Content-Type: application/json -``` - -**Request body**: - -```json -{ - "keys": ["new-api-key"], - "accounts": [...] -} -``` - ---- - -### Account Management - -#### Add account - -```http -POST /admin/accounts -Authorization: Bearer -Content-Type: application/json -``` - -**Request body**: - -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -#### Batch import accounts - -```http -POST /admin/accounts/batch -Authorization: Bearer -Content-Type: application/json -``` - -**Request body**: - -```json -{ - "accounts": [ - {"email": "user1@example.com", "password": "pass1"}, - {"email": "user2@example.com", "password": "pass2"} - ] -} -``` - -#### Test one account - -```http -POST /admin/accounts/test -Authorization: Bearer -Content-Type: application/json -``` - -**Request body**: - -```json -{ - "email": "user@example.com" -} -``` - -#### Test all accounts - -```http -POST /admin/accounts/test-all -Authorization: Bearer -``` - -#### Queue status - -```http -GET /admin/queue/status -Authorization: Bearer -``` - -**Response**: - -```json -{ - "total_accounts": 5, - "healthy_accounts": 4, - "queue_size": 10, - "accounts": [ - { - "email": "user@example.com", - "status": "healthy", - "last_used": "2026-02-01T12:00:00Z" - } - ] -} -``` - ---- - -### Vercel Sync - -```http -POST /admin/vercel/sync -Authorization: Bearer -Content-Type: application/json -``` - -**Request body** (first sync only): - -```json -{ - "vercel_token": "your-vercel-token", - "project_id": "your-project-id" -} -``` - -> After a successful first sync, credentials are stored for future syncs. - -**Response**: - -```json -{ - "success": true, - "message": "Configuration synced to Vercel" -} -``` - ---- - -## Error Handling - -All error responses follow this structure: - -```json -{ - "error": { - "message": "Error description", - "type": "error_type", - "code": "error_code" + ], + "claude_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" } } ``` -**Common error codes**: +### `POST /admin/config` -| HTTP Status | Error Type | Description | -|:----------:|---------|------| -| 400 | `invalid_request_error` | Invalid request parameters | -| 401 | `authentication_error` | Missing or invalid API key | -| 403 | `permission_denied` | Insufficient permissions | -| 429 | `rate_limit_error` | Too many requests | -| 500 | `internal_error` | Internal server error | -| 503 | `service_unavailable` | No available accounts | +Updatable fields: `keys`, `accounts`, `claude_mapping`. ---- +Request example: -## Examples - -### Python (OpenAI SDK) - -```python -from openai import OpenAI - -client = OpenAI( - api_key="your-api-key", - base_url="https://your-domain.com/v1" -) - -# Basic chat -response = client.chat.completions.create( - model="deepseek-chat", - messages=[{"role": "user", "content": "Hello"}] -) -print(response.choices[0].message.content) - -# Streaming + reasoning -for chunk in client.chat.completions.create( - model="deepseek-reasoner", - messages=[{"role": "user", "content": "Explain relativity"}], - stream=True -): - delta = chunk.choices[0].delta - if hasattr(delta, 'reasoning_content') and delta.reasoning_content: - print(f"[Reasoning] {delta.reasoning_content}", end="") - if delta.content: - print(delta.content, end="") +```json +{ + "keys": ["k1", "k2"], + "accounts": [ + {"email": "user@example.com", "password": "pwd", "token": ""} + ], + "claude_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" + } +} ``` -### Python (Anthropic SDK) +### `POST /admin/keys` -```python -import anthropic - -client = anthropic.Anthropic( - api_key="your-api-key", - base_url="https://your-domain.com/anthropic" -) - -response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1024, - messages=[{"role": "user", "content": "Hello"}] -) -print(response.content[0].text) +```json +{"key":"new-api-key"} ``` -### cURL +Response: + +```json +{"success":true,"total_keys":3} +``` + +### `DELETE /admin/keys/{key}` + +```json +{"success":true,"total_keys":2} +``` + +### `GET /admin/accounts` + +Query params: + +- `page` (default 1) +- `page_size` (default 10, max 100) + +Response: + +```json +{ + "items": [ + { + "email": "user@example.com", + "mobile": "", + "has_password": true, + "has_token": true, + "token_preview": "abc..." + } + ], + "total": 25, + "page": 1, + "page_size": 10, + "total_pages": 3 +} +``` + +### `POST /admin/accounts` + +```json +{"email":"user@example.com","password":"pwd"} +``` + +```json +{"success":true,"total_accounts":6} +``` + +### `DELETE /admin/accounts/{identifier}` + +`identifier` is email or mobile. + +```json +{"success":true,"total_accounts":5} +``` + +### `GET /admin/queue/status` + +```json +{ + "available": 3, + "in_use": 1, + "total": 4, + "available_accounts": ["a@example.com"], + "in_use_accounts": ["b@example.com"] +} +``` + +### `POST /admin/accounts/test` + +Request fields: + +| Field | Required | Notes | +| --- | --- | --- | +| `identifier` | yes | email or mobile | +| `model` | no | default `deepseek-chat` | +| `message` | no | if empty, only session creation is tested | + +Response example: + +```json +{ + "account": "user@example.com", + "success": true, + "response_time": 1240, + "message": "API 测试成功(仅会话创建)", + "model": "deepseek-chat" +} +``` + +### `POST /admin/accounts/test-all` + +Optional request field: `model`. + +```json +{ + "total": 5, + "success": 4, + "failed": 1, + "results": [] +} +``` + +### `POST /admin/import` + +```json +{ + "keys": ["k1", "k2"], + "accounts": [ + {"email":"user@example.com","password":"pwd","token":""} + ] +} +``` + +```json +{ + "success": true, + "imported_keys": 2, + "imported_accounts": 1 +} +``` + +### `POST /admin/test` + +Optional request fields: + +- `model` (default `deepseek-chat`) +- `message` (default `你好`) +- `api_key` (default first key in config) + +Response example: + +```json +{ + "success": true, + "status_code": 200, + "response": {"id":"..."} +} +``` + +### `POST /admin/vercel/sync` + +Request fields: + +| Field | Required | Notes | +| --- | --- | --- | +| `vercel_token` | no | if empty or `__USE_PRECONFIG__`, read env | +| `project_id` | no | fallback: `VERCEL_PROJECT_ID` | +| `team_id` | no | fallback: `VERCEL_TEAM_ID` | +| `auto_validate` | no | default `true` | +| `save_credentials` | no | default `true` | + +Success response example: + +```json +{ + "success": true, + "validated_accounts": 3, + "message": "配置已同步,正在重新部署...", + "deployment_url": "https://..." +} +``` + +Or manual deploy required: + +```json +{ + "success": true, + "validated_accounts": 3, + "message": "配置已同步到 Vercel,请手动触发重新部署", + "manual_deploy_required": true +} +``` + +### `GET /admin/vercel/status` + +```json +{ + "synced": true, + "last_sync_time": 1738400000, + "has_synced_before": true +} +``` + +### `GET /admin/export` + +```json +{ + "json": "{...}", + "base64": "ey4uLn0=" +} +``` + +## Error Payloads + +Error payload formats are not fully unified in current code: + +- OpenAI routes often return: `{"error":"..."}` +- Claude routes often return: `{"error":{"type":"...","message":"..."}}` +- Admin routes often return: `{"detail":"..."}` + +Clients should handle HTTP status code plus `error` / `detail` fields. + +## cURL Examples + +### OpenAI non-stream ```bash -# OpenAI format -curl https://your-domain.com/v1/chat/completions \ - -H "Content-Type: application/json" \ +curl http://localhost:5001/v1/chat/completions \ -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", - "messages": [{"role": "user", "content": "Hello"}] + "messages": [{"role": "user", "content": "Hello"}], + "stream": false }' +``` -# Claude format -curl https://your-domain.com/anthropic/v1/messages \ +### OpenAI stream + +```bash +curl http://localhost:5001/v1/chat/completions \ + -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-reasoner", + "messages": [{"role": "user", "content": "Explain quantum entanglement"}], + "stream": true + }' +``` + +### Claude + +```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-20250514", @@ -639,63 +640,3 @@ curl https://your-domain.com/anthropic/v1/messages \ "messages": [{"role": "user", "content": "Hello"}] }' ``` - -### JavaScript / TypeScript - -```javascript -// OpenAI format - streaming request -const response = await fetch('https://your-domain.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-api-key' - }, - body: JSON.stringify({ - model: 'deepseek-chat-search', - messages: [{ role: 'user', content: 'What is in the news today?' }], - stream: true - }) -}); - -const reader = response.body.getReader(); -const decoder = new TextDecoder(); - -while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n').filter(line => line.startsWith('data: ')); - - for (const line of lines) { - const data = line.slice(6); - if (data === '[DONE]') continue; - - const json = JSON.parse(data); - const content = json.choices?.[0]?.delta?.content; - if (content) process.stdout.write(content); - } -} -``` - -### Node.js (OpenAI SDK) - -```javascript -import OpenAI from 'openai'; - -const client = new OpenAI({ - apiKey: 'your-api-key', - baseURL: 'https://your-domain.com/v1' -}); - -const stream = await client.chat.completions.create({ - model: 'deepseek-reasoner', - messages: [{ role: 'user', content: 'Explain black holes' }], - stream: true -}); - -for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content; - if (content) process.stdout.write(content); -} -``` diff --git a/API.md b/API.md index 58b22fa..c3b4f3c 100644 --- a/API.md +++ b/API.md @@ -1,331 +1,289 @@ -# DS2API 接口文档 +# DS2API 接口文档(Go 实现) 语言 / Language: [中文](API.md) | [English](API.en.md) -本文档详细介绍 DS2API 提供的所有 API 端点。 - ---- - -## 目录 - -- [基础信息](#基础信息) -- [OpenAI 兼容接口](#openai-兼容接口) - - [获取模型列表](#获取模型列表) - - [对话补全](#对话补全) -- [Claude 兼容接口](#claude-兼容接口) - - [Claude 模型列表](#claude-模型列表) - - [Claude 消息接口](#claude-消息接口) - - [Token 计数](#token-计数) -- [管理接口](#管理接口) - - [登录认证](#登录认证) - - [配置管理](#配置管理) - - [账号管理](#账号管理) - - [Vercel 同步](#vercel-同步) -- [错误处理](#错误处理) -- [使用示例](#使用示例) - ---- +本文档描述当前代码库的实际 API 行为(Go 后端)。 ## 基础信息 -| 项目 | 说明 | -|-----|------| -| **Base URL** | `https://your-domain.com` 或 `http://localhost:5001` | -| **OpenAI 认证** | `Authorization: Bearer ` | -| **Claude 认证** | `x-api-key: ` | -| **响应格式** | JSON | +- Base URL:`http://localhost:5001` 或你的部署域名 +- 默认返回:`application/json` +- 健康检查:`GET /healthz`、`GET /readyz` ---- +### 鉴权规则 + +业务接口(`/v1/*`、`/anthropic/*`)支持两种传参: + +1. `Authorization: Bearer ` +2. `x-api-key: `(无 `Bearer` 前缀) + +Admin 接口: + +- `POST /admin/login` 无需鉴权 +- `GET /admin/verify` 需要 `Authorization: Bearer `(仅 JWT) +- 其他 `/admin/*` 保护接口支持: +- `Authorization: Bearer ` +- `Authorization: Bearer `(直传管理密钥) + +## 路由总览 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/healthz` | 存活探针 | +| GET | `/readyz` | 就绪探针 | +| GET | `/v1/models` | OpenAI 模型列表 | +| POST | `/v1/chat/completions` | OpenAI 对话补全 | +| GET | `/anthropic/v1/models` | Claude 模型列表 | +| POST | `/anthropic/v1/messages` | Claude 消息接口 | +| POST | `/anthropic/v1/messages/count_tokens` | Claude token 计数 | +| POST | `/admin/login` | 管理登录 | +| GET | `/admin/verify` | 校验管理 JWT | +| GET | `/admin/vercel/config` | 读取 Vercel 预配置 | +| GET | `/admin/config` | 读取配置(脱敏) | +| POST | `/admin/config` | 更新配置 | +| POST | `/admin/keys` | 添加 API key | +| DELETE | `/admin/keys/{key}` | 删除 API key | +| GET | `/admin/accounts` | 分页账号列表 | +| POST | `/admin/accounts` | 添加账号 | +| DELETE | `/admin/accounts/{identifier}` | 删除账号 | +| GET | `/admin/queue/status` | 账号队列状态 | +| POST | `/admin/accounts/test` | 测试单个账号 | +| POST | `/admin/accounts/test-all` | 测试全部账号 | +| POST | `/admin/import` | 批量导入 keys/accounts | +| POST | `/admin/test` | 测试当前 API 可用性 | +| POST | `/admin/vercel/sync` | 同步配置到 Vercel | +| GET | `/admin/vercel/status` | Vercel 同步状态 | +| GET | `/admin/export` | 导出配置 JSON/Base64 | + +## 健康检查 + +### `GET /healthz` + +响应: + +```json +{"status":"ok"} +``` + +### `GET /readyz` + +响应: + +```json +{"status":"ready"} +``` ## OpenAI 兼容接口 -### 获取模型列表 +### `GET /v1/models` -```http -GET /v1/models -``` +无需鉴权。 -**响应示例**: +响应示例: ```json { "object": "list", "data": [ - {"id": "deepseek-chat", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-reasoner", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-chat-search", "object": "model", "owned_by": "deepseek"}, - {"id": "deepseek-reasoner-search", "object": "model", "owned_by": "deepseek"} + {"id": "deepseek-chat", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-reasoner", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-chat-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []}, + {"id": "deepseek-reasoner-search", "object": "model", "created": 1677610602, "owned_by": "deepseek", "permission": []} ] } ``` ---- +### `POST /v1/chat/completions` -### 对话补全 +请求头示例: ```http -POST /v1/chat/completions Authorization: Bearer your-api-key Content-Type: application/json ``` -**请求参数**: +请求体核心字段: -| 参数 | 类型 | 必填 | 说明 | -|-----|------|:----:|------| -| `model` | string | ✅ | 模型名称(见下表) | -| `messages` | array | ✅ | 对话消息列表 | -| `stream` | boolean | ❌ | 是否流式输出,默认 `false` | -| `temperature` | number | ❌ | 温度参数,范围 0-2 | -| `max_tokens` | number | ❌ | 最大输出 token 数 | -| `tools` | array | ❌ | 工具定义列表(Function Calling) | -| `tool_choice` | string | ❌ | 工具选择策略 | +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `model` | string | 是 | `deepseek-chat` / `deepseek-reasoner` / `deepseek-chat-search` / `deepseek-reasoner-search` | +| `messages` | array | 是 | OpenAI 风格消息数组 | +| `stream` | boolean | 否 | 默认 `false` | +| `tools` | array | 否 | Function Calling 定义 | +| `temperature` 等 | any | 否 | 兼容透传字段(最终是否生效由上游决定) | -**支持的模型**: - -| 模型 | 深度思考 | 联网搜索 | 说明 | -|-----|:--------:|:--------:|------| -| `deepseek-chat` | ❌ | ❌ | 标准对话 | -| `deepseek-reasoner` | ✅ | ❌ | 推理模式,输出思考过程 | -| `deepseek-chat-search` | ❌ | ✅ | 联网搜索增强 | -| `deepseek-reasoner-search` | ✅ | ✅ | 推理 + 联网搜索 | - -**基础请求示例**: +非流式响应示例: ```json { - "model": "deepseek-chat", - "messages": [ - {"role": "system", "content": "你是一个有帮助的助手。"}, - {"role": "user", "content": "你好"} - ] -} -``` - -**流式请求示例**: - -```json -{ - "model": "deepseek-reasoner-search", - "messages": [ - {"role": "user", "content": "今天有什么重要新闻?"} - ], - "stream": true -} -``` - -**流式响应格式** (`stream: true`): - -``` -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":[{"index":0,"finish_reason":"stop"}]} - -data: [DONE] -``` - -> **注意**:推理模式会输出 `reasoning_content` 字段,包含模型的思考过程。 - -**非流式响应格式** (`stream: false`): - -```json -{ - "id": "chatcmpl-xxx", + "id": "", "object": "chat.completion", "created": 1738400000, "model": "deepseek-reasoner", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": "回复内容", - "reasoning_content": "思考过程(仅 reasoner 模型)" - }, - "finish_reason": "stop" - }], + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "最终回复", + "reasoning_content": "思考内容(reasoner 模型)" + }, + "finish_reason": "stop" + } + ], "usage": { "prompt_tokens": 10, - "completion_tokens": 50, - "total_tokens": 60, + "completion_tokens": 20, + "total_tokens": 30, "completion_tokens_details": { - "reasoning_tokens": 20 + "reasoning_tokens": 5 } } } ``` -#### 工具调用 (Function Calling) +### OpenAI 流式(`stream=true`) -**请求示例**: +SSE 格式:每段为 `data: \n\n`,结束为 `data: [DONE]`。 + +- 首次 delta 可能包含 `role: assistant` +- reasoner 模型会输出 `delta.reasoning_content` +- 普通文本输出 `delta.content` +- 最后一段包含 `finish_reason`,并附带 usage + +示例: + +```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] +``` + +### Tool Calls(重点) + +请求中带 `tools` 时,服务端会注入工具提示并尝试解析模型输出。 + +- 非流式:若识别到工具调用,返回 `message.tool_calls`,并设置 `finish_reason=tool_calls`,`message.content=null` +- 流式:为防止原始 toolcall JSON 泄漏,正文会先缓冲;若识别到工具调用,仅输出结构化 `delta.tool_calls` + +工具调用响应示例: ```json { - "model": "deepseek-chat", - "messages": [{"role": "user", "content": "北京今天天气怎么样?"}], - "tools": [{ - "type": "function", - "function": { - "name": "get_weather", - "description": "获取指定城市的天气", - "parameters": { - "type": "object", - "properties": { - "location": {"type": "string", "description": "城市名称"} - }, - "required": ["location"] - } + "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" } - }] + ] } ``` -**响应示例**: - -```json -{ - "id": "chatcmpl-xxx", - "object": "chat.completion", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", - "content": null, - "tool_calls": [{ - "id": "call_xxx", - "type": "function", - "function": { - "name": "get_weather", - "arguments": "{\"location\": \"北京\"}" - } - }] - }, - "finish_reason": "tool_calls" - }] -} -``` - ---- - ## Claude 兼容接口 -### Claude 模型列表 +### `GET /anthropic/v1/models` -```http -GET /anthropic/v1/models -``` +无需鉴权。 -**响应示例**: +响应示例: ```json { "object": "list", "data": [ - {"id": "claude-sonnet-4-20250514", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-sonnet-4-20250514-fast", "object": "model", "owned_by": "anthropic"}, - {"id": "claude-sonnet-4-20250514-slow", "object": "model", "owned_by": "anthropic"} + {"id": "claude-sonnet-4-20250514", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-20250514-fast", "object": "model", "created": 1715635200, "owned_by": "anthropic"}, + {"id": "claude-sonnet-4-20250514-slow", "object": "model", "created": 1715635200, "owned_by": "anthropic"} ] } ``` -**模型映射说明**: +### `POST /anthropic/v1/messages` -| Claude 模型 | 实际调用 | 说明 | -|------------|---------|------| -| `claude-sonnet-4-20250514` | deepseek-chat | 标准模式 | -| `claude-sonnet-4-20250514-fast` | deepseek-chat | 快速模式 | -| `claude-sonnet-4-20250514-slow` | deepseek-reasoner | 推理模式(深度思考) | - ---- - -### Claude 消息接口 +请求头可用: ```http -POST /anthropic/v1/messages x-api-key: your-api-key Content-Type: application/json anthropic-version: 2023-06-01 ``` -**请求参数**: +请求体核心字段: -| 参数 | 类型 | 必填 | 说明 | -|-----|------|:----:|------| -| `model` | string | ✅ | 模型名称 | -| `max_tokens` | integer | ✅ | 最大输出 token | -| `messages` | array | ✅ | 对话消息 | -| `stream` | boolean | ❌ | 是否流式,默认 `false` | -| `system` | string | ❌ | 系统提示词 | -| `temperature` | number | ❌ | 温度参数 | +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `model` | string | 是 | `claude-sonnet-4-20250514` / `-fast` / `-slow` | +| `messages` | array | 是 | Claude 风格消息数组 | +| `max_tokens` | number | 否 | 当前实现不会硬性截断上游输出 | +| `stream` | boolean | 否 | 默认 `false` | +| `system` | string | 否 | 可选系统提示 | +| `tools` | array | 否 | Claude tool 定义 | -**请求示例**: +非流式响应示例: ```json { - "model": "claude-sonnet-4-20250514", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "你好,请介绍一下你自己"} - ] -} -``` - -**非流式响应**: - -```json -{ - "id": "msg_xxx", + "id": "msg_1738400000000000000", "type": "message", "role": "assistant", - "content": [{ - "type": "text", - "text": "你好!我是一个 AI 助手..." - }], "model": "claude-sonnet-4-20250514", + "content": [ + {"type": "text", "text": "回复内容"} + ], "stop_reason": "end_turn", + "stop_sequence": null, "usage": { - "input_tokens": 10, - "output_tokens": 50 + "input_tokens": 12, + "output_tokens": 34 } } ``` -**流式响应** (SSE): +若识别到工具调用,`stop_reason=tool_use`,并在 `content` 中返回 `tool_use` block。 -``` -event: message_start -data: {"type":"message_start","message":{"id":"msg_xxx","type":"message","role":"assistant","model":"claude-sonnet-4-20250514"}} +### Claude 流式(`stream=true`) + +返回同样是 SSE,但当前实现仅写入 `data:` 行,不输出 `event:` 行。每条 JSON 内包含 `type` 字段。 + +示例: + +```text +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":"你好"}} +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}} -event: content_block_stop data: {"type":"content_block_stop","index":0} -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":50}} +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}} -event: message_stop data: {"type":"message_stop"} ``` ---- +### `POST /anthropic/v1/messages/count_tokens` -### Token 计数 - -```http -POST /anthropic/v1/messages/count_tokens -x-api-key: your-api-key -Content-Type: application/json -``` - -**请求示例**: +请求示例: ```json { @@ -336,7 +294,7 @@ Content-Type: application/json } ``` -**响应示例**: +响应示例: ```json { @@ -344,294 +302,365 @@ Content-Type: application/json } ``` ---- +## Admin 接口 -## 管理接口 +### `POST /admin/login` -所有管理接口(除登录外)需要在请求头携带 JWT Token:`Authorization: Bearer ` - -### 登录认证 - -```http -POST /admin/login -Content-Type: application/json -``` - -**请求体**: +请求: ```json { - "key": "your-admin-key" + "admin_key": "admin", + "expire_hours": 24 } ``` -**响应**: +说明:`expire_hours` 可省略,默认 24。 + +响应: ```json { "success": true, - "token": "jwt-token-string", + "token": "", "expires_in": 86400 } ``` -> Token 有效期默认 24 小时。 +### `GET /admin/verify` ---- +请求头:`Authorization: Bearer ` -### 配置管理 - -#### 获取配置 - -```http -GET /admin/config -Authorization: Bearer -``` - -**响应**: +响应: ```json { - "keys": ["api-key-1", "api-key-2"], + "valid": true, + "expires_at": 1738400000, + "remaining_seconds": 72000 +} +``` + +### `GET /admin/vercel/config` + +返回是否存在 Vercel 预配置: + +```json +{ + "has_token": true, + "project_id": "prj_xxx", + "team_id": null +} +``` + +### `GET /admin/config` + +返回脱敏配置: + +```json +{ + "keys": ["k1", "k2"], "accounts": [ { "email": "user@example.com", - "password": "***", - "token": "session-token" + "mobile": "", + "has_password": true, + "has_token": true, + "token_preview": "abcde..." } - ] -} -``` - -#### 更新配置 - -```http -POST /admin/config -Authorization: Bearer -Content-Type: application/json -``` - -**请求体**: - -```json -{ - "keys": ["new-api-key"], - "accounts": [...] -} -``` - ---- - -### 账号管理 - -#### 添加账号 - -```http -POST /admin/accounts -Authorization: Bearer -Content-Type: application/json -``` - -**请求体**: - -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -#### 批量导入账号 - -```http -POST /admin/accounts/batch -Authorization: Bearer -Content-Type: application/json -``` - -**请求体**: - -```json -{ - "accounts": [ - {"email": "user1@example.com", "password": "pass1"}, - {"email": "user2@example.com", "password": "pass2"} - ] -} -``` - -#### 测试单个账号 - -```http -POST /admin/accounts/test -Authorization: Bearer -Content-Type: application/json -``` - -**请求体**: - -```json -{ - "email": "user@example.com" -} -``` - -#### 测试所有账号 - -```http -POST /admin/accounts/test-all -Authorization: Bearer -``` - -#### 获取队列状态 - -```http -GET /admin/queue/status -Authorization: Bearer -``` - -**响应**: - -```json -{ - "total_accounts": 5, - "healthy_accounts": 4, - "queue_size": 10, - "accounts": [ - { - "email": "user@example.com", - "status": "healthy", - "last_used": "2026-02-01T12:00:00Z" - } - ] -} -``` - ---- - -### Vercel 同步 - -```http -POST /admin/vercel/sync -Authorization: Bearer -Content-Type: application/json -``` - -**请求体**(首次同步需要): - -```json -{ - "vercel_token": "your-vercel-token", - "project_id": "your-project-id" -} -``` - -> 首次同步成功后,凭证会被保存,后续同步可不传。 - -**响应**: - -```json -{ - "success": true, - "message": "配置已同步到 Vercel" -} -``` - ---- - -## 错误处理 - -所有错误响应遵循以下格式: - -```json -{ - "error": { - "message": "错误描述", - "type": "error_type", - "code": "error_code" + ], + "claude_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" } } ``` -**常见错误码**: +### `POST /admin/config` -| HTTP 状态码 | 错误类型 | 说明 | -|:----------:|---------|------| -| 400 | `invalid_request_error` | 请求参数错误 | -| 401 | `authentication_error` | API Key 无效或未提供 | -| 403 | `permission_denied` | 权限不足 | -| 429 | `rate_limit_error` | 请求过于频繁 | -| 500 | `internal_error` | 服务器内部错误 | -| 503 | `service_unavailable` | 无可用账号 | +可更新 `keys`、`accounts`、`claude_mapping`。 ---- +请求示例: -## 使用示例 - -### Python (OpenAI SDK) - -```python -from openai import OpenAI - -client = OpenAI( - api_key="your-api-key", - base_url="https://your-domain.com/v1" -) - -# 普通对话 -response = client.chat.completions.create( - model="deepseek-chat", - messages=[{"role": "user", "content": "你好"}] -) -print(response.choices[0].message.content) - -# 流式 + 推理模式 -for chunk in client.chat.completions.create( - model="deepseek-reasoner", - messages=[{"role": "user", "content": "解释相对论"}], - stream=True -): - delta = chunk.choices[0].delta - if hasattr(delta, 'reasoning_content') and delta.reasoning_content: - print(f"[思考] {delta.reasoning_content}", end="") - if delta.content: - print(delta.content, end="") +```json +{ + "keys": ["k1", "k2"], + "accounts": [ + {"email": "user@example.com", "password": "pwd", "token": ""} + ], + "claude_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" + } +} ``` -### Python (Anthropic SDK) +### `POST /admin/keys` -```python -import anthropic +请求: -client = anthropic.Anthropic( - api_key="your-api-key", - base_url="https://your-domain.com/anthropic" -) - -response = client.messages.create( - model="claude-sonnet-4-20250514", - max_tokens=1024, - messages=[{"role": "user", "content": "你好"}] -) -print(response.content[0].text) +```json +{"key":"new-api-key"} ``` -### cURL +响应: + +```json +{"success":true,"total_keys":3} +``` + +### `DELETE /admin/keys/{key}` + +响应: + +```json +{"success":true,"total_keys":2} +``` + +### `GET /admin/accounts` + +查询参数: + +- `page`(默认 1) +- `page_size`(默认 10,最大 100) + +响应: + +```json +{ + "items": [ + { + "email": "user@example.com", + "mobile": "", + "has_password": true, + "has_token": true, + "token_preview": "abc..." + } + ], + "total": 25, + "page": 1, + "page_size": 10, + "total_pages": 3 +} +``` + +### `POST /admin/accounts` + +请求示例: + +```json +{"email":"user@example.com","password":"pwd"} +``` + +响应: + +```json +{"success":true,"total_accounts":6} +``` + +### `DELETE /admin/accounts/{identifier}` + +`identifier` 为 email 或 mobile。 + +响应: + +```json +{"success":true,"total_accounts":5} +``` + +### `GET /admin/queue/status` + +响应: + +```json +{ + "available": 3, + "in_use": 1, + "total": 4, + "available_accounts": ["a@example.com"], + "in_use_accounts": ["b@example.com"] +} +``` + +### `POST /admin/accounts/test` + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `identifier` | 是 | email 或 mobile | +| `model` | 否 | 默认 `deepseek-chat` | +| `message` | 否 | 空字符串时仅测试建会话 | + +响应示例: + +```json +{ + "account": "user@example.com", + "success": true, + "response_time": 1240, + "message": "API 测试成功(仅会话创建)", + "model": "deepseek-chat" +} +``` + +### `POST /admin/accounts/test-all` + +请求可选:`model` + +响应示例: + +```json +{ + "total": 5, + "success": 4, + "failed": 1, + "results": [] +} +``` + +### `POST /admin/import` + +请求支持同时导入 `keys` 与 `accounts`: + +```json +{ + "keys": ["k1", "k2"], + "accounts": [ + {"email":"user@example.com","password":"pwd","token":""} + ] +} +``` + +响应: + +```json +{ + "success": true, + "imported_keys": 2, + "imported_accounts": 1 +} +``` + +### `POST /admin/test` + +请求字段(均可选): + +- `model`(默认 `deepseek-chat`) +- `message`(默认 `你好`) +- `api_key`(默认使用配置中第一个 key) + +响应示例: + +```json +{ + "success": true, + "status_code": 200, + "response": {"id":"..."} +} +``` + +### `POST /admin/vercel/sync` + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `vercel_token` | 否 | 传空或 `__USE_PRECONFIG__` 则读环境变量 | +| `project_id` | 否 | 为空则读 `VERCEL_PROJECT_ID` | +| `team_id` | 否 | 为空则读 `VERCEL_TEAM_ID` | +| `auto_validate` | 否 | 默认 `true` | +| `save_credentials` | 否 | 默认 `true` | + +成功响应示例: + +```json +{ + "success": true, + "validated_accounts": 3, + "message": "配置已同步,正在重新部署...", + "deployment_url": "https://..." +} +``` + +或: + +```json +{ + "success": true, + "validated_accounts": 3, + "message": "配置已同步到 Vercel,请手动触发重新部署", + "manual_deploy_required": true +} +``` + +### `GET /admin/vercel/status` + +响应: + +```json +{ + "synced": true, + "last_sync_time": 1738400000, + "has_synced_before": true +} +``` + +### `GET /admin/export` + +响应: + +```json +{ + "json": "{...}", + "base64": "ey4uLn0=" +} +``` + +## 错误响应格式 + +不同模块错误格式不完全一致(按当前实现): + +- OpenAI 接口常见:`{"error":"..."}` +- Claude 接口常见:`{"error":{"type":"...","message":"..."}}` +- Admin 接口常见:`{"detail":"..."}` + +建议客户端至少处理:HTTP 状态码 + `error` / `detail` 字段。 + +## cURL 示例 + +### OpenAI 非流式 ```bash -# OpenAI 格式 -curl https://your-domain.com/v1/chat/completions \ - -H "Content-Type: application/json" \ +curl http://localhost:5001/v1/chat/completions \ -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", - "messages": [{"role": "user", "content": "你好"}] + "messages": [{"role": "user", "content": "你好"}], + "stream": false }' +``` -# Claude 格式 -curl https://your-domain.com/anthropic/v1/messages \ +### OpenAI 流式 + +```bash +curl http://localhost:5001/v1/chat/completions \ + -H "Authorization: Bearer your-api-key" \ -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-reasoner", + "messages": [{"role": "user", "content": "解释一下量子纠缠"}], + "stream": true + }' +``` + +### Claude + +```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-20250514", @@ -639,63 +668,3 @@ curl https://your-domain.com/anthropic/v1/messages \ "messages": [{"role": "user", "content": "你好"}] }' ``` - -### JavaScript / TypeScript - -```javascript -// OpenAI 格式 - 流式请求 -const response = await fetch('https://your-domain.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-api-key' - }, - body: JSON.stringify({ - model: 'deepseek-chat-search', - messages: [{ role: 'user', content: '今天有什么新闻?' }], - stream: true - }) -}); - -const reader = response.body.getReader(); -const decoder = new TextDecoder(); - -while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n').filter(line => line.startsWith('data: ')); - - for (const line of lines) { - const data = line.slice(6); - if (data === '[DONE]') continue; - - const json = JSON.parse(data); - const content = json.choices?.[0]?.delta?.content; - if (content) process.stdout.write(content); - } -} -``` - -### Node.js (OpenAI SDK) - -```javascript -import OpenAI from 'openai'; - -const client = new OpenAI({ - apiKey: 'your-api-key', - baseURL: 'https://your-domain.com/v1' -}); - -const stream = await client.chat.completions.create({ - model: 'deepseek-reasoner', - messages: [{ role: 'user', content: '解释黑洞' }], - stream: true -}); - -for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content; - if (content) process.stdout.write(content); -} -``` diff --git a/DEPLOY.en.md b/DEPLOY.en.md index 42ce015..17555fb 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -2,31 +2,48 @@ Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md) -## Contents +This guide is aligned with the current Go codebase. -- Vercel deployment -- Docker deployment -- Local run -- systemd deployment +## Deployment Modes -## Vercel Deployment +- Local run: `go run ./cmd/ds2api` +- Docker: `docker-compose up -d` +- Vercel: serverless entry at `api/index.go` +- Linux service mode: systemd -1. Import the repository into Vercel -2. Set required environment variables: -- `DS2API_ADMIN_KEY` -- `DS2API_CONFIG_JSON` (JSON or Base64) -3. Deploy and open `/admin` +## 0. Prerequisites -The project uses `api/index.go` as the serverless entrypoint. See `vercel.json`. +- Go 1.25+ +- Node.js 20+ (only if you need to build WebUI locally) +- `config.json` or `DS2API_CONFIG_JSON` -## Docker Deployment +## 1. Local Run + +```bash +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api + +cp config.example.json config.json +# edit config.json + +go run ./cmd/ds2api +``` + +Default port is `5001` (override with `PORT`). + +Build WebUI if `/admin` reports missing assets: + +```bash +./scripts/build-webui.sh +``` + +## 2. Docker Deployment ```bash cp .env.example .env # edit .env docker-compose up -d - docker-compose logs -f ``` @@ -36,20 +53,50 @@ Rebuild after updates: docker-compose up -d --build ``` -## Local Run +Notes: -```bash -cp config.example.json config.json -# edit config +- `Dockerfile` uses multi-stage build (WebUI + Go binary) +- Container entry command is `/usr/local/bin/ds2api` -go run ./cmd/ds2api +## 3. Vercel Deployment + +- Serverless entry: `api/index.go` +- Rewrites and cache headers: `vercel.json` + +Minimum environment variables: + +- `DS2API_ADMIN_KEY` +- `DS2API_CONFIG_JSON` (raw JSON or Base64) + +Optional: + +- `VERCEL_TOKEN` +- `VERCEL_PROJECT_ID` +- `VERCEL_TEAM_ID` + +After deploy, verify: + +- `/healthz` +- `/v1/models` +- `/admin` + +## 4. Reverse Proxy (Nginx) + +Disable buffering for SSE: + +```nginx +location / { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + tcp_nodelay on; +} ``` -Default port is `5001` (override with `PORT`). - -## systemd Deployment (Linux) - -Example unit file: +## 5. systemd Example (Linux) ```ini [Unit] @@ -61,7 +108,7 @@ Type=simple WorkingDirectory=/opt/ds2api Environment=PORT=5001 Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json -Environment=DS2API_ADMIN_KEY=your-admin-secret-key +Environment=DS2API_ADMIN_KEY=admin ExecStart=/opt/ds2api/ds2api Restart=always RestartSec=5 @@ -70,7 +117,7 @@ RestartSec=5 WantedBy=multi-user.target ``` -Useful commands: +Common commands: ```bash sudo systemctl daemon-reload @@ -78,3 +125,17 @@ sudo systemctl enable ds2api sudo systemctl start ds2api sudo systemctl status ds2api ``` + +## 6. Post-Deploy Checks + +```bash +curl -s http://127.0.0.1:5001/healthz +curl -s http://127.0.0.1:5001/readyz +curl -s http://127.0.0.1:5001/v1/models +``` + +If admin UI is required: + +```bash +curl -s http://127.0.0.1:5001/admin +``` diff --git a/DEPLOY.md b/DEPLOY.md index b9547b0..1017e9e 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,55 +1,102 @@ -# DS2API 部署指南(Go 版本) +# DS2API 部署指南(Go) 语言 / Language: [中文](DEPLOY.md) | [English](DEPLOY.en.md) -## 目录 +本指南基于当前 Go 代码库。 -- Vercel 部署 -- Docker 部署 -- 本地运行 -- systemd 部署 +## 部署方式 -## Vercel 部署 +- 本地运行:`go run ./cmd/ds2api` +- Docker:`docker-compose up -d` +- Vercel:`api/index.go` serverless 入口 +- Linux 服务化:systemd -1. 导入仓库到 Vercel -2. 设置环境变量(至少): -- `DS2API_ADMIN_KEY` -- `DS2API_CONFIG_JSON`(JSON 或 Base64) -3. 部署后访问 `/admin` 管理界面 +## 0. 前置要求 -说明:项目使用 `api/index.go` 作为 Serverless 入口,配置见 `vercel.json`。 +- Go 1.25+ +- Node.js 20+(仅在需要本地构建 WebUI 时) +- `config.json` 或 `DS2API_CONFIG_JSON` -## Docker 部署 +## 1. 本地运行 + +```bash +git clone https://github.com/CJackHwang/ds2api.git +cd ds2api + +cp config.example.json config.json +# 编辑 config.json + +go run ./cmd/ds2api +``` + +默认监听 `5001`,可通过 `PORT` 覆盖。 + +构建 WebUI(可选,仅当 `/admin` 缺少静态文件时): + +```bash +./scripts/build-webui.sh +``` + +## 2. Docker 部署 ```bash cp .env.example .env # 编辑 .env docker-compose up -d - docker-compose logs -f ``` -更新后重建: +更新镜像: ```bash docker-compose up -d --build ``` -## 本地运行 +说明: -```bash -cp config.example.json config.json -# 编辑配置 +- `Dockerfile` 使用多阶段构建(WebUI + Go 二进制) +- 容器内默认启动命令:`/usr/local/bin/ds2api` -go run ./cmd/ds2api +## 3. Vercel 部署 + +- serverless 入口:`api/index.go` +- 路由与缓存头:`vercel.json` + +至少配置环境变量: + +- `DS2API_ADMIN_KEY` +- `DS2API_CONFIG_JSON`(JSON 或 Base64) + +可选: + +- `VERCEL_TOKEN` +- `VERCEL_PROJECT_ID` +- `VERCEL_TEAM_ID` + +部署后建议先访问: + +- `/healthz` +- `/v1/models` +- `/admin` + +## 4. 反向代理(Nginx) + +如果在 Nginx 后挂载,建议关闭缓冲以保证 SSE: + +```nginx +location / { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + tcp_nodelay on; +} ``` -默认端口 `5001`,可通过 `PORT` 环境变量覆盖。 - -## systemd 部署(Linux) - -示例服务文件: +## 5. systemd 示例(Linux) ```ini [Unit] @@ -61,7 +108,7 @@ Type=simple WorkingDirectory=/opt/ds2api Environment=PORT=5001 Environment=DS2API_CONFIG_PATH=/opt/ds2api/config.json -Environment=DS2API_ADMIN_KEY=your-admin-secret-key +Environment=DS2API_ADMIN_KEY=admin ExecStart=/opt/ds2api/ds2api Restart=always RestartSec=5 @@ -78,3 +125,17 @@ sudo systemctl enable ds2api sudo systemctl start ds2api sudo systemctl status ds2api ``` + +## 6. 部署后检查 + +```bash +curl -s http://127.0.0.1:5001/healthz +curl -s http://127.0.0.1:5001/readyz +curl -s http://127.0.0.1:5001/v1/models +``` + +如果你依赖管理台接口,再检查: + +```bash +curl -s http://127.0.0.1:5001/admin +``` diff --git a/README.MD b/README.MD index 21b7a10..2f49d55 100644 --- a/README.MD +++ b/README.MD @@ -4,95 +4,94 @@ ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt) -[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-部署推荐) +[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md) 语言 / Language: [中文](README.MD) | [English](README.en.md) -将 DeepSeek 免费对话版转换为 **OpenAI & Claude 兼容 API**,支持多账号轮询、自动 Token 刷新、可视化管理界面。 +将 DeepSeek Web 对话能力转换为 OpenAI 与 Claude 兼容 API。当前仓库后端为 **Go 全量实现**,前端保留 React WebUI(构建产物托管于 `static/admin`)。 -![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532) -![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a) -![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9) -![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675) +## 当前实现边界 +- 后端:Go(`cmd/`, `api/`, `internal/`),不再依赖 Python 运行时 +- 前端:React 管理台(源码在 `webui/`,运行时托管静态构建) +- 部署:本地运行、Docker、Vercel Serverless +## 核心能力 -## ✨ 特性 +- OpenAI 兼容:`/v1/models`、`/v1/chat/completions` +- Claude 兼容:`/anthropic/v1/models`、`/anthropic/v1/messages`、`/anthropic/v1/messages/count_tokens` +- 多账号轮询与自动 token 刷新 +- DeepSeek PoW(WASM)计算 +- Admin API:配置管理、账号测试、导入导出、Vercel 同步 +- WebUI:`/admin` 单页应用托管 +- 运维探针:`/healthz`、`/readyz` -- 🔄 **双协议兼容** - 同时支持 OpenAI 和 Claude (Anthropic) API 格式 -- 🚀 **多账号轮询** - Round-Robin 负载均衡,支持高并发场景 -- 🔐 **Token 自动刷新** - 过期自动重新登录,无需手动维护 -- 🌐 **WebUI 管理** - 可视化添加账号、测试 API、同步 Vercel 配置 -- 🌍 **多语言切换** - WebUI 内置中英双语,可随时切换 -- 🔍 **联网搜索** - 支持 DeepSeek 原生搜索增强模式 -- 🧠 **深度思考** - 支持推理模式,输出思考过程 -- 🛠️ **工具调用** - 兼容 OpenAI Function Calling 格式 -- ☁️ **Vercel 一键部署** - 无需服务器,快速上线 +## 模型支持 -## 📋 模型支持 +### OpenAI 接口 -### OpenAI 兼容接口 (`/v1/chat/completions`) +| 模型 | thinking | search | +| --- | --- | --- | +| `deepseek-chat` | false | false | +| `deepseek-reasoner` | true | false | +| `deepseek-chat-search` | false | true | +| `deepseek-reasoner-search` | true | true | -| 模型 | 深度思考 | 联网搜索 | 说明 | -|-----|:--------:|:--------:|------| -| `deepseek-chat` | ❌ | ❌ | 标准对话模式 | -| `deepseek-reasoner` | ✅ | ❌ | 推理模式(输出思考过程) | -| `deepseek-chat-search` | ❌ | ✅ | 联网搜索模式 | -| `deepseek-reasoner-search` | ✅ | ✅ | 推理 + 联网搜索 | +### Claude 接口 -### Claude 兼容接口 (`/anthropic/v1/messages`) +| 模型 | 默认映射 | +| --- | --- | +| `claude-sonnet-4-20250514` | `deepseek-chat` | +| `claude-sonnet-4-20250514-fast` | `deepseek-chat` | +| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` | -| 模型 | 说明 | -|-----|------| -| `claude-sonnet-4-20250514` | 映射到 deepseek-chat(标准模式) | -| `claude-sonnet-4-20250514-fast` | 映射到 deepseek-chat(快速模式) | -| `claude-sonnet-4-20250514-slow` | 映射到 deepseek-reasoner(推理模式) | +可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射。 -> **提示**:Claude 接口实际调用的是 DeepSeek,响应格式会自动转换为 Anthropic 标准格式。 +## 快速开始 -## 🚀 快速开始 +### 1) 本地运行 -### 方式一:Vercel 部署(推荐) - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=管理面板访问密码(必填)&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23环境变量&project-name=ds2api&repository-name=ds2api) - -1. 点击上方按钮,设置管理密码 `DS2API_ADMIN_KEY` -2. 部署完成后访问 `/admin` 管理界面 -3. 添加 DeepSeek 账号和自定义 API Key -4. 点击「同步到 Vercel」保存配置 - -> **首次同步会自动验证账号并保存 Token,后续操作无需重复输入凭证。** - -### 方式二:本地开发 +要求:Go 1.25+ ```bash -# 1. 克隆仓库 git clone https://github.com/CJackHwang/ds2api.git cd ds2api -# 2. 准备配置 cp config.example.json config.json -# 编辑 config.json,添加 DeepSeek 账号信息 +# 编辑 config.json -# 3. 启动服务(Go 版本) go run ./cmd/ds2api ``` -服务启动后访问 `http://localhost:5001` +默认地址:`http://localhost:5001` -## ⚙️ 配置说明 +如果访问 `/admin` 提示未构建 WebUI,请执行: -### 环境变量 +```bash +./scripts/build-webui.sh +``` -| 变量 | 说明 | 必填 | -|-----|------|:----:| -| `DS2API_ADMIN_KEY` | 管理面板密码 | Vercel 必填 | -| `DS2API_CONFIG_JSON` | 配置 JSON 或 Base64 编码 | 可选 | -| `VERCEL_TOKEN` | Vercel API Token(用于同步) | 可选 | -| `VERCEL_PROJECT_ID` | Vercel 项目 ID | 可选 | -| `PORT` | 服务端口(默认 5001) | 可选 | +### 2) Docker 运行 -### 配置文件格式 (`config.json`) +```bash +cp .env.example .env +# 编辑 .env + +docker-compose up -d +docker-compose logs -f +``` + +### 3) Vercel 部署 + +- 入口:`api/index.go` +- 路由重写:`vercel.json` +- 至少配置: +- `DS2API_ADMIN_KEY` +- `DS2API_CONFIG_JSON`(JSON 字符串或 Base64) + +## 配置说明 + +### `config.json` 示例 ```json { @@ -108,125 +107,59 @@ go run ./cmd/ds2api "password": "your-password", "token": "" } - ] + ], + "claude_model_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" + } } ``` -> **说明**: -> - `keys`: 自定义的 API 密钥,用于调用本服务 -> - `accounts`: DeepSeek 网页版账号,支持邮箱或手机号登录 -> - `token`: 留空即可,系统会自动获取并刷新 +### 环境变量(核心) -## 📡 API 使用 +| 变量 | 用途 | +| --- | --- | +| `PORT` | 服务端口,默认 `5001` | +| `LOG_LEVEL` | 日志级别:`DEBUG/INFO/WARN/ERROR` | +| `DS2API_ADMIN_KEY` | Admin 登录密钥,默认 `admin` | +| `DS2API_JWT_SECRET` | Admin JWT 签名密钥(可选) | +| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT 过期小时数,默认 `24` | +| `DS2API_CONFIG_PATH` | 配置文件路径,默认 `config.json` | +| `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | +| `DS2API_WASM_PATH` | PoW wasm 文件路径 | +| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | +| `VERCEL_TOKEN` | Vercel 同步 token(可选) | +| `VERCEL_PROJECT_ID` | Vercel 项目 ID(可选) | +| `VERCEL_TEAM_ID` | Vercel 团队 ID(可选) | -完整 API 文档请参阅 **[API.md](API.md)** +## 鉴权与账号模式 -### 快速示例 +调用业务接口时(`/v1/*`, `/anthropic/*`)支持两种模式: -**获取模型列表**: -```bash -curl http://localhost:5001/v1/models -``` +1. 托管账号模式:`Bearer` 或 `x-api-key` 使用 `config.keys` 中的 key。 +2. 直通 token 模式:当传入 token 不在 `config.keys` 中时,服务直接把它当作 DeepSeek token 使用。 -**OpenAI 格式调用**: -```bash -curl http://localhost:5001/v1/chat/completions \ - -H "Authorization: Bearer your-api-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "deepseek-chat", - "messages": [{"role": "user", "content": "你好"}], - "stream": true - }' -``` +可选请求头:`X-Ds2-Target-Account`,用于指定托管账号。 -**Claude 格式调用**: -```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-20250514", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "你好"}] - }' -``` +## Tool Call 适配说明 -### Python SDK 使用 +当前实现对 toolcall 做了防泄漏处理: -```python -from openai import OpenAI +- `tools` + `stream=true` 时,服务端会先缓冲正文片段 +- 若识别到工具调用,会只输出结构化 `tool_calls`,不透传原始 JSON 文本 +- 若最终不是工具调用,再一次性输出普通文本 +- 解析器支持混合文本、fenced JSON、`function.arguments` 字符串等格式 -client = OpenAI( - api_key="your-api-key", - base_url="http://localhost:5001/v1" -) +## 文档与测试 -response = client.chat.completions.create( - model="deepseek-reasoner", - messages=[{"role": "user", "content": "请解释量子纠缠"}], - stream=True -) - -for chunk in response: - if chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="") -``` - -## 🔧 部署配置 - -### Nginx 反向代理 - -```nginx -location / { - proxy_pass http://localhost:5001; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_buffering off; - proxy_cache off; - chunked_transfer_encoding on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 120; -} -``` - -### 方式三:Docker 部署 +- API 文档:`API.md` / `API.en.md` +- 部署文档:`DEPLOY.md` / `DEPLOY.en.md` +- 贡献指南:`CONTRIBUTING.md` / `CONTRIBUTING.en.md` ```bash -# 1. 克隆仓库并进入目录 -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 2. 配置环境变量 -cp .env.example .env -# 编辑 .env,填写 DS2API_ADMIN_KEY 和 DS2API_CONFIG_JSON - -# 3. 启动服务 -docker-compose up -d - -# 4. 查看日志 -docker-compose logs -f +go test ./... ``` -> **Docker 优势**:零侵入设计,主代码更新只需 `docker-compose up -d --build`,无需修改 Docker 配置。详见 [DEPLOY.md](DEPLOY.md#docker-部署推荐)。 +## 免责声明 -## ⚠️ 免责声明 - -**本项目基于逆向工程实现,服务稳定性无法保证。** - -- 仅供学习研究使用,**禁止商业用途或对外提供服务** -- 建议正式项目使用 [DeepSeek 官方 API](https://platform.deepseek.com/) -- 使用本项目产生的任何风险由用户自行承担 - -## 📜 鸣谢 - -本项目基于以下开源项目: - -- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api) -- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api) - -## 📊 Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date) +本项目基于逆向方式实现,仅供学习与研究使用。稳定性和可用性不作保证,请勿用于违反服务条款或法律法规的场景。 diff --git a/README.en.md b/README.en.md index 2da100a..82ca039 100644 --- a/README.en.md +++ b/README.en.md @@ -4,93 +4,94 @@ ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Version](https://img.shields.io/badge/version-1.6.11-blue.svg)](version.txt) -[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md#docker-deployment-recommended) +[![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md) Language: [中文](README.MD) | [English](README.en.md) -Convert DeepSeek Web into an **OpenAI & Claude compatible API**, with multi-account rotation, automatic token refresh, and a visual admin console. +DS2API converts DeepSeek Web chat capability into OpenAI-compatible and Claude-compatible APIs. The current repository is **Go backend only** with the existing React WebUI kept as static assets under `static/admin`. -![p1](https://github.com/user-attachments/assets/07296a50-50d4-4f05-a9e5-280df14e9532) -![p2](https://github.com/user-attachments/assets/03b4a763-766f-4050-aea8-1a183e70ae6a) -![p3](https://github.com/user-attachments/assets/fc8b9836-11e3-4c38-a684-eb2c79b80fe9) -![p4](https://github.com/user-attachments/assets/513e9ca7-aa9e-45a6-8f7e-f362b1650675) +## Implementation Boundary -## ✨ Features +- Backend: Go (`cmd/`, `api/`, `internal/`), no Python runtime +- Frontend: React admin panel (`webui/` source, static build served at runtime) +- Deployment: local run, Docker, Vercel serverless -- 🔄 **Dual-protocol support** - OpenAI and Claude (Anthropic) compatible APIs -- 🚀 **Multi-account rotation** - Round-robin load balancing for high concurrency -- 🔐 **Automatic token refresh** - Re-auth on expiry without manual maintenance -- 🌐 **WebUI management** - Add accounts, test APIs, and sync Vercel settings visually -- 🌍 **Language toggle** - Built-in Chinese and English UI switcher -- 🔍 **Web search** - DeepSeek native search enhancement mode -- 🧠 **Deep reasoning** - Reasoning mode with trace output -- 🛠️ **Tool calling** - OpenAI Function Calling compatible -- ☁️ **One-click Vercel deploy** - No server required +## Key Capabilities -## 📋 Model Support +- OpenAI-compatible endpoints: `GET /v1/models`, `POST /v1/chat/completions` +- Claude-compatible endpoints: `GET /anthropic/v1/models`, `POST /anthropic/v1/messages`, `POST /anthropic/v1/messages/count_tokens` +- Multi-account rotation and automatic token refresh +- DeepSeek PoW solving via WASM +- Admin API: config management, account tests, import/export, Vercel sync +- WebUI SPA hosting at `/admin` +- Health probes: `GET /healthz`, `GET /readyz` -### OpenAI compatible endpoint (`/v1/chat/completions`) +## Model Support -| Model | Reasoning | Search | Notes | -|-----|:--------:|:------:|------| -| `deepseek-chat` | ❌ | ❌ | Standard chat | -| `deepseek-reasoner` | ✅ | ❌ | Reasoning (shows trace) | -| `deepseek-chat-search` | ❌ | ✅ | Web search mode | -| `deepseek-reasoner-search` | ✅ | ✅ | Reasoning + search | +### OpenAI endpoint -### Claude compatible endpoint (`/anthropic/v1/messages`) +| Model | thinking | search | +| --- | --- | --- | +| `deepseek-chat` | false | false | +| `deepseek-reasoner` | true | false | +| `deepseek-chat-search` | false | true | +| `deepseek-reasoner-search` | true | true | -| Model | Notes | -|-----|------| -| `claude-sonnet-4-20250514` | Maps to deepseek-chat (standard) | -| `claude-sonnet-4-20250514-fast` | Maps to deepseek-chat (fast) | -| `claude-sonnet-4-20250514-slow` | Maps to deepseek-reasoner (reasoning) | +### Claude endpoint -> **Tip**: The Claude endpoint actually calls DeepSeek and returns Anthropic-format responses. +| Model | Default mapping | +| --- | --- | +| `claude-sonnet-4-20250514` | `deepseek-chat` | +| `claude-sonnet-4-20250514-fast` | `deepseek-chat` | +| `claude-sonnet-4-20250514-slow` | `deepseek-reasoner` | -## 🚀 Quick Start +You can override mapping via `claude_mapping` or `claude_model_mapping` in config. -### Option 1: Vercel deployment (recommended) +## Quick Start -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api&env=DS2API_ADMIN_KEY&envDescription=Admin%20console%20access%20key%20%28required%29&envLink=https%3A%2F%2Fgithub.com%2FCJackHwang%2Fds2api%23environment-variables&project-name=ds2api&repository-name=ds2api) +### 1) Local run -1. Click the button above and set `DS2API_ADMIN_KEY` -2. After deployment, visit `/admin` -3. Add DeepSeek accounts and custom API keys -4. Click "Sync to Vercel" to persist configuration - -> **First sync validates accounts and stores tokens automatically.** - -### Option 2: Local development +Requirement: Go 1.25+ ```bash -# 1. Clone the repo git clone https://github.com/CJackHwang/ds2api.git cd ds2api -# 2. Configure accounts cp config.example.json config.json -# Edit config.json to add DeepSeek account info +# edit config.json -# 3. Start the service (Go runtime) go run ./cmd/ds2api ``` -Visit `http://localhost:5001` after startup. +Default URL: `http://localhost:5001` -## ⚙️ Configuration +If `/admin` says WebUI not built: -### Environment variables +```bash +./scripts/build-webui.sh +``` -| Variable | Description | Required | -|-----|------|:----:| -| `DS2API_ADMIN_KEY` | Admin console password | Required on Vercel | -| `DS2API_CONFIG_JSON` | Config JSON or Base64 | Optional | -| `VERCEL_TOKEN` | Vercel API token (for sync) | Optional | -| `VERCEL_PROJECT_ID` | Vercel project ID | Optional | -| `PORT` | Service port (default 5001) | Optional | +### 2) Docker -### Config file format (`config.json`) +```bash +cp .env.example .env +# edit .env + +docker-compose up -d +docker-compose logs -f +``` + +### 3) Vercel + +- Entrypoint: `api/index.go` +- Rewrites: `vercel.json` +- Minimum env vars: +- `DS2API_ADMIN_KEY` +- `DS2API_CONFIG_JSON` (raw JSON or Base64) + +## Configuration + +### `config.json` example ```json { @@ -106,125 +107,59 @@ Visit `http://localhost:5001` after startup. "password": "your-password", "token": "" } - ] + ], + "claude_model_mapping": { + "fast": "deepseek-chat", + "slow": "deepseek-reasoner" + } } ``` -> **Notes**: -> - `keys`: Custom API keys for calling this service -> - `accounts`: DeepSeek Web accounts (email or mobile) -> - `token`: Leave blank; DS2API will fetch and refresh automatically +### Core environment variables -## 📡 API Usage +| Variable | Purpose | +| --- | --- | +| `PORT` | Service port, default `5001` | +| `LOG_LEVEL` | `DEBUG/INFO/WARN/ERROR` | +| `DS2API_ADMIN_KEY` | Admin login key, default `admin` | +| `DS2API_JWT_SECRET` | Admin JWT signing secret (optional) | +| `DS2API_JWT_EXPIRE_HOURS` | Admin JWT TTL in hours, default `24` | +| `DS2API_CONFIG_PATH` | Config file path, default `config.json` | +| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | +| `DS2API_WASM_PATH` | PoW wasm path | +| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | +| `VERCEL_TOKEN` | Vercel sync token (optional) | +| `VERCEL_PROJECT_ID` | Vercel project ID (optional) | +| `VERCEL_TEAM_ID` | Vercel team ID (optional) | -See **[API.md](API.md)** for full API documentation. +## Auth and Account Modes -### Quick examples +For business endpoints (`/v1/*`, `/anthropic/*`), DS2API supports two modes: -**List models**: -```bash -curl http://localhost:5001/v1/models -``` +1. Managed account mode: use a key from `config.keys` via `Authorization: Bearer ...` or `x-api-key`. +2. Direct token mode: if the incoming token is not in `config.keys`, DS2API treats it as a DeepSeek token directly. -**OpenAI-compatible call**: -```bash -curl http://localhost:5001/v1/chat/completions \ - -H "Authorization: Bearer your-api-key" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "deepseek-chat", - "messages": [{"role": "user", "content": "Hello"}], - "stream": true - }' -``` +Optional header: `X-Ds2-Target-Account` to pin one managed account. -**Claude-compatible call**: -```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-20250514", - "max_tokens": 1024, - "messages": [{"role": "user", "content": "Hello"}] - }' -``` +## Tool Call Adaptation -### Python SDK usage +Tool-call leakage is handled in the current implementation: -```python -from openai import OpenAI +- With `tools` + `stream=true`, DS2API buffers text deltas first +- If a tool call is detected, DS2API returns structured `tool_calls` only +- If no tool call is detected, DS2API emits the buffered text once +- Parser supports mixed text, fenced JSON, and `function.arguments` payloads -client = OpenAI( - api_key="your-api-key", - base_url="http://localhost:5001/v1" -) +## Docs and Testing -response = client.chat.completions.create( - model="deepseek-reasoner", - messages=[{"role": "user", "content": "Explain quantum entanglement"}], - stream=True -) - -for chunk in response: - if chunk.choices[0].delta.content: - print(chunk.choices[0].delta.content, end="") -``` - -## 🔧 Deployment Notes - -### Nginx reverse proxy - -```nginx -location / { - proxy_pass http://localhost:5001; - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_buffering off; - proxy_cache off; - chunked_transfer_encoding on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 120; -} -``` - -### Option 3: Docker deployment +- API docs: `API.md` / `API.en.md` +- Deployment docs: `DEPLOY.md` / `DEPLOY.en.md` +- Contributing: `CONTRIBUTING.md` / `CONTRIBUTING.en.md` ```bash -# 1. Clone the repo and enter the directory -git clone https://github.com/CJackHwang/ds2api.git -cd ds2api - -# 2. Configure environment variables -cp .env.example .env -# Edit .env and fill in DS2API_ADMIN_KEY and DS2API_CONFIG_JSON - -# 3. Start the service -docker-compose up -d - -# 4. Check logs -docker-compose logs -f +go test ./... ``` -> **Docker advantage**: Zero-intrusion design; update the main code with `docker-compose up -d --build` without changing Docker configuration. See [DEPLOY.md](DEPLOY.md#docker-deployment-recommended). +## Disclaimer -## ⚠️ Disclaimer - -**This project is based on reverse engineering and stability is not guaranteed.** - -- For learning and research only. **No commercial use or public service is allowed.** -- For production, use the official [DeepSeek API](https://platform.deepseek.com/) -- You assume all risks from using this project - -## 📜 Acknowledgements - -This project is based on the following open-source projects: - -- [iidamie/deepseek2api](https://github.com/iidamie/deepseek2api) -- [LLM-Red-Team/deepseek-free-api](https://github.com/LLM-Red-Team/deepseek-free-api) - -## 📊 Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=CJackHwang/ds2api&type=Date)](https://star-history.com/#CJackHwang/ds2api&Date) +This project is built through reverse engineering and is provided for learning and research only. Stability is not guaranteed. Do not use it in scenarios that violate terms of service or laws. diff --git a/internal/adapter/openai/handler.go b/internal/adapter/openai/handler.go index e06c9c8..a212299 100644 --- a/internal/adapter/openai/handler.go +++ b/internal/adapter/openai/handler.go @@ -211,6 +211,7 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt created := time.Now().Unix() firstChunkSent := false + bufferToolContent := len(toolNames) > 0 currentType := "text" if thinkingEnabled { currentType = "thinking" @@ -240,12 +241,34 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt detected := util.ParseToolCalls(finalText, toolNames) if len(detected) > 0 { finishReason = "tool_calls" + delta := map[string]any{ + "tool_calls": util.FormatOpenAIToolCalls(detected), + } + if !firstChunkSent { + delta["role"] = "assistant" + firstChunkSent = true + } sendChunk(map[string]any{ "id": completionID, "object": "chat.completion.chunk", "created": created, "model": model, - "choices": []map[string]any{{"delta": map[string]any{"tool_calls": util.FormatOpenAIToolCalls(detected)}, "index": 0}}, + "choices": []map[string]any{{"delta": delta, "index": 0}}, + }) + } else if bufferToolContent && strings.TrimSpace(finalText) != "" { + delta := map[string]any{ + "content": finalText, + } + if !firstChunkSent { + delta["role"] = "assistant" + firstChunkSent = true + } + sendChunk(map[string]any{ + "id": completionID, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": []map[string]any{{"delta": delta, "index": 0}}, }) } promptTokens := util.EstimateTokens(finalPrompt) @@ -325,7 +348,9 @@ func (h *Handler) handleStream(w http.ResponseWriter, r *http.Request, resp *htt } } else { text.WriteString(p.Text) - delta["content"] = p.Text + if !bufferToolContent { + delta["content"] = p.Text + } } if len(delta) > 0 { newChoices = append(newChoices, map[string]any{"delta": delta, "index": 0}) diff --git a/internal/auth/admin.go b/internal/auth/admin.go index 739e79f..6b19285 100644 --- a/internal/auth/admin.go +++ b/internal/auth/admin.go @@ -17,7 +17,7 @@ func AdminKey() string { if v := strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")); v != "" { return v } - return "your-admin-secret-key" + return "admin" } func jwtSecret() string { diff --git a/internal/auth/request.go b/internal/auth/request.go index 819665e..d36f4b1 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -15,7 +15,7 @@ type ctxKey string const authCtxKey ctxKey = "auth_context" var ( - ErrUnauthorized = errors.New("unauthorized: missing Bearer token") + ErrUnauthorized = errors.New("unauthorized: missing auth token") ErrNoAccount = errors.New("no accounts configured or all accounts are busy") ) @@ -41,11 +41,10 @@ func NewResolver(store *config.Store, pool *account.Pool, login LoginFunc) *Reso } func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) { - authHeader := req.Header.Get("Authorization") - if !strings.HasPrefix(authHeader, "Bearer ") { + callerKey := extractCallerToken(req) + if callerKey == "" { return nil, ErrUnauthorized } - callerKey := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) ctx := req.Context() if !r.Store.HasAPIKey(callerKey) { return &RequestAuth{UseConfigToken: false, DeepSeekToken: callerKey, resolver: r, TriedAccounts: map[string]bool{}}, nil @@ -148,3 +147,14 @@ func (r *Resolver) Release(a *RequestAuth) { } r.Pool.Release(a.AccountID) } + +func extractCallerToken(req *http.Request) string { + authHeader := strings.TrimSpace(req.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + token := strings.TrimSpace(authHeader[7:]) + if token != "" { + return token + } + } + return strings.TrimSpace(req.Header.Get("x-api-key")) +} diff --git a/internal/auth/request_test.go b/internal/auth/request_test.go new file mode 100644 index 0000000..1d568f3 --- /dev/null +++ b/internal/auth/request_test.go @@ -0,0 +1,74 @@ +package auth + +import ( + "context" + "net/http" + "testing" + + "ds2api/internal/account" + "ds2api/internal/config" +) + +func newTestResolver(t *testing.T) *Resolver { + t.Helper() + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["managed-key"], + "accounts":[{"email":"acc@example.com","password":"pwd","token":"account-token"}] + }`) + store := config.LoadStore() + pool := account.NewPool(store) + return NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) { + return "fresh-token", nil + }) +} + +func TestDetermineWithXAPIKeyUsesDirectToken(t *testing.T) { + r := newTestResolver(t) + req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + req.Header.Set("x-api-key", "direct-token") + + auth, err := r.Determine(req) + if err != nil { + t.Fatalf("determine failed: %v", err) + } + if auth.UseConfigToken { + t.Fatalf("expected direct token mode") + } + if auth.DeepSeekToken != "direct-token" { + t.Fatalf("unexpected token: %q", auth.DeepSeekToken) + } +} + +func TestDetermineWithXAPIKeyManagedKeyAcquiresAccount(t *testing.T) { + r := newTestResolver(t) + req, _ := http.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + req.Header.Set("x-api-key", "managed-key") + + auth, err := r.Determine(req) + if err != nil { + t.Fatalf("determine failed: %v", err) + } + defer r.Release(auth) + if !auth.UseConfigToken { + t.Fatalf("expected managed key mode") + } + if auth.AccountID != "acc@example.com" { + t.Fatalf("unexpected account id: %q", auth.AccountID) + } + if auth.DeepSeekToken != "account-token" { + t.Fatalf("unexpected account token: %q", auth.DeepSeekToken) + } +} + +func TestDetermineMissingToken(t *testing.T) { + r := newTestResolver(t) + req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + _, err := r.Determine(req) + if err == nil { + t.Fatal("expected unauthorized error") + } + if err != ErrUnauthorized { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index af2fc6a..e7b2d62 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -356,5 +356,5 @@ func (s *Store) ClaudeMapping() map[string]string { if len(s.cfg.ClaudeMapping) > 0 { return cloneStringMap(s.cfg.ClaudeMapping) } - return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-chat"} + return map[string]string{"fast": "deepseek-chat", "slow": "deepseek-reasoner"} } diff --git a/internal/util/toolcalls.go b/internal/util/toolcalls.go index ec1b33e..d15160c 100644 --- a/internal/util/toolcalls.go +++ b/internal/util/toolcalls.go @@ -9,6 +9,7 @@ import ( ) var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`) +var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```") type ParsedToolCall struct { Name string `json:"name"` @@ -19,23 +20,25 @@ func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { if strings.TrimSpace(text) == "" { return nil } - m := toolCallPattern.FindStringSubmatch(text) - if len(m) < 2 { - return nil - } - payload := "{" + `"tool_calls":[` + m[1] + "]}" - var obj struct { - ToolCalls []ParsedToolCall `json:"tool_calls"` - } - if err := json.Unmarshal([]byte(payload), &obj); err != nil { + + candidates := buildToolCallCandidates(text) + var parsed []ParsedToolCall + for _, candidate := range candidates { + if tc := parseToolCallsPayload(candidate); len(tc) > 0 { + parsed = tc + break + } + } + if len(parsed) == 0 { return nil } + allowed := map[string]struct{}{} for _, name := range availableToolNames { allowed[name] = struct{}{} } - out := make([]ParsedToolCall, 0, len(obj.ToolCalls)) - for _, tc := range obj.ToolCalls { + out := make([]ParsedToolCall, 0, len(parsed)) + for _, tc := range parsed { if tc.Name == "" { continue } @@ -52,6 +55,220 @@ func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { return out } +func buildToolCallCandidates(text string) []string { + trimmed := strings.TrimSpace(text) + candidates := []string{trimmed} + + // fenced code block candidates: ```json ... ``` + for _, match := range fencedJSONPattern.FindAllStringSubmatch(trimmed, -1) { + if len(match) >= 2 { + candidates = append(candidates, strings.TrimSpace(match[1])) + } + } + + // best-effort extraction around "tool_calls" key in mixed text payloads. + candidates = append(candidates, extractToolCallObjects(trimmed)...) + + // best-effort object slice: from first '{' to last '}' + first := strings.Index(trimmed, "{") + last := strings.LastIndex(trimmed, "}") + if first >= 0 && last > first { + candidates = append(candidates, strings.TrimSpace(trimmed[first:last+1])) + } + + // legacy regex extraction fallback + if m := toolCallPattern.FindStringSubmatch(trimmed); len(m) >= 2 { + candidates = append(candidates, "{"+`"tool_calls":[`+m[1]+"]}") + } + + uniq := make([]string, 0, len(candidates)) + seen := map[string]struct{}{} + for _, c := range candidates { + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + uniq = append(uniq, c) + } + return uniq +} + +func parseToolCallsPayload(payload string) []ParsedToolCall { + var decoded any + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + return nil + } + switch v := decoded.(type) { + case map[string]any: + if tc, ok := v["tool_calls"]; ok { + return parseToolCallList(tc) + } + if parsed, ok := parseToolCallItem(v); ok { + return []ParsedToolCall{parsed} + } + case []any: + return parseToolCallList(v) + } + return nil +} + +func parseToolCallList(v any) []ParsedToolCall { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]ParsedToolCall, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]any) + if !ok { + continue + } + if tc, ok := parseToolCallItem(m); ok { + out = append(out, tc) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) { + name, _ := m["name"].(string) + inputRaw, hasInput := m["input"] + if fn, ok := m["function"].(map[string]any); ok { + if name == "" { + name, _ = fn["name"].(string) + } + if !hasInput { + if v, ok := fn["arguments"]; ok { + inputRaw = v + hasInput = true + } + } + } + if !hasInput { + for _, key := range []string{"arguments", "args", "parameters", "params"} { + if v, ok := m[key]; ok { + inputRaw = v + hasInput = true + break + } + } + } + if strings.TrimSpace(name) == "" { + return ParsedToolCall{}, false + } + return ParsedToolCall{ + Name: strings.TrimSpace(name), + Input: parseToolCallInput(inputRaw), + }, true +} + +func parseToolCallInput(v any) map[string]any { + switch x := v.(type) { + case nil: + return map[string]any{} + case map[string]any: + return x + case string: + raw := strings.TrimSpace(x) + if raw == "" { + return map[string]any{} + } + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil { + return parsed + } + return map[string]any{"_raw": raw} + default: + b, err := json.Marshal(x) + if err != nil { + return map[string]any{} + } + var parsed map[string]any + if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil { + return parsed + } + return map[string]any{} + } +} + +func extractToolCallObjects(text string) []string { + if text == "" { + return nil + } + lower := strings.ToLower(text) + out := []string{} + offset := 0 + for { + idx := strings.Index(lower[offset:], "tool_calls") + if idx < 0 { + break + } + idx += offset + start := strings.LastIndex(text[:idx], "{") + for start >= 0 { + candidate, end, ok := extractJSONObject(text, start) + if ok { + // Move forward to avoid repeatedly matching the same object. + offset = end + out = append(out, strings.TrimSpace(candidate)) + break + } + start = strings.LastIndex(text[:start], "{") + } + if start < 0 { + offset = idx + len("tool_calls") + } + } + return out +} + +func extractJSONObject(text string, start int) (string, int, bool) { + if start < 0 || start >= len(text) || text[start] != '{' { + return "", 0, false + } + depth := 0 + quote := byte(0) + escaped := false + for i := start; i < len(text); i++ { + ch := text[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == quote { + quote = 0 + } + continue + } + if ch == '"' || ch == '\'' { + quote = ch + continue + } + if ch == '{' { + depth++ + continue + } + if ch == '}' { + depth-- + if depth == 0 { + return text[start : i+1], i + 1, true + } + } + } + return "", 0, false +} + func FormatOpenAIToolCalls(calls []ParsedToolCall) []map[string]any { out := make([]map[string]any, 0, len(calls)) for _, c := range calls { diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 87f0ae1..de27b13 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -11,6 +11,34 @@ func TestParseToolCalls(t *testing.T) { if calls[0].Name != "search" { t.Fatalf("unexpected tool name: %s", calls[0].Name) } + if calls[0].Input["q"] != "golang" { + t.Fatalf("unexpected args: %#v", calls[0].Input) + } +} + +func TestParseToolCallsFromFencedJSON(t *testing.T) { + text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```" + calls := ParseToolCalls(text, []string{"search"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0].Input["q"] != "news" { + t.Fatalf("unexpected args: %#v", calls[0].Input) + } +} + +func TestParseToolCallsWithFunctionArgumentsString(t *testing.T) { + text := `{"tool_calls":[{"function":{"name":"get_weather","arguments":"{\"city\":\"beijing\"}"}}]}` + calls := ParseToolCalls(text, []string{"get_weather"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0].Name != "get_weather" { + t.Fatalf("unexpected tool name: %s", calls[0].Name) + } + if calls[0].Input["city"] != "beijing" { + t.Fatalf("unexpected args: %#v", calls[0].Input) + } } func TestParseToolCallsRejectUnknown(t *testing.T) {