diff --git a/API.md b/API.md new file mode 100644 index 0000000..1bdc314 --- /dev/null +++ b/API.md @@ -0,0 +1,369 @@ +# API 文档 + +本文档详细介绍 DS2API 提供的所有 API 端点。 + +## 基础信息 + +- **Base URL**: `https://your-domain.com` 或 `http://localhost:5001` +- **认证方式**: Bearer Token +- **响应格式**: JSON + +## OpenAI 兼容接口 + +### 获取模型列表 + +```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"} + ] +} +``` + +--- + +### 对话补全 + +```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 数 | + +**支持的模型**: + +| 模型 | thinking_enabled | search_enabled | 说明 | +|-----|-----------------|----------------|------| +| `deepseek-chat` | ❌ | ❌ | 标准对话 | +| `deepseek-reasoner` | ✅ | ❌ | 深度思考(R1 推理) | +| `deepseek-chat-search` | ❌ | ✅ | 联网搜索 | +| `deepseek-reasoner-search` | ✅ | ✅ | 深度思考 + 联网搜索 | + +**请求示例**: + +```json +{ + "model": "deepseek-reasoner-search", + "messages": [ + {"role": "system", "content": "你是一个有帮助的助手。"}, + {"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] +``` + +**非流式响应格式** (`stream: false`): + +```json +{ + "id": "chatcmpl-xxx", + "object": "chat.completion", + "created": 1699000000, + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "回复内容", + "reasoning_content": "思考过程(仅 reasoner 模型)" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 50, + "total_tokens": 60 + } +} +``` + +--- + +## 管理接口 + +所有管理接口需要在请求头中携带 `Authorization: Bearer `。 + +### 登录 + +```http +POST /admin/login +Content-Type: application/json +``` + +**请求体**: + +```json +{ + "key": "your-admin-key" +} +``` + +**响应**: + +```json +{ + "success": true, + "token": "jwt-token", + "expires_in": 86400 +} +``` + +--- + +### 获取配置 + +```http +GET /admin/config +Authorization: Bearer +``` + +**响应**: + +```json +{ + "keys": ["api-key-1", "api-key-2"], + "accounts": [ + { + "email": "user@example.com", + "password": "***", + "token": "session-token" + } + ] +} +``` + +--- + +### 添加账号 + +```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-01T06: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 +{ + "error": { + "message": "错误描述", + "type": "error_type", + "code": "error_code" + } +} +``` + +**常见错误码**: + +| HTTP 状态码 | 错误类型 | 说明 | +|------------|---------|------| +| 400 | `invalid_request_error` | 请求参数错误 | +| 401 | `authentication_error` | API Key 无效或未提供 | +| 403 | `permission_denied` | 权限不足 | +| 429 | `rate_limit_error` | 请求过于频繁 | +| 500 | `internal_error` | 服务器内部错误 | +| 503 | `service_unavailable` | 无可用账号 | + +--- + +## 使用示例 + +### Python + +```python +import openai + +client = openai.OpenAI( + api_key="your-api-key", + base_url="https://your-domain.com/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="") +``` + +### cURL + +```bash +curl https://your-domain.com/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your-api-key" \ + -d '{ + "model": "deepseek-chat", + "messages": [{"role": "user", "content": "你好"}] + }' +``` + +### JavaScript + +```javascript +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; + console.log(decoder.decode(value)); +} +``` diff --git a/README.MD b/README.MD index 9eb8a8c..4ff5a8c 100644 --- a/README.MD +++ b/README.MD @@ -85,27 +85,21 @@ python dev.py ## 📡 API 接口 -### 模型列表 +完整的 API 文档请参阅 **[API.md](API.md)**。 +### 快速示例 + +**模型列表**: ``` GET /v1/models ``` -### 对话补全 - -``` -POST /v1/chat/completions -Authorization: Bearer your-api-key -``` - -请求示例: - -```json -{ - "model": "deepseek-chat", - "messages": [{"role": "user", "content": "你好"}], - "stream": true -} +**对话补全**: +```bash +curl https://your-domain.com/v1/chat/completions \ + -H "Authorization: Bearer your-api-key" \ + -H "Content-Type: application/json" \ + -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"你好"}]}' ``` ### 管理接口 diff --git a/routes/openai.py b/routes/openai.py index 960129f..587ee92 100644 --- a/routes/openai.py +++ b/routes/openai.py @@ -171,7 +171,12 @@ async def chat_completions(request: Request): result_queue.put(None) return - # logger.info(f"[sse_stream] RAW 原始chunk: {data_str[:300]}") + logger.info(f"[sse_stream] RAW 原始chunk: {data_str[:300]}") + print(f"[DEBUG] RAW: {data_str[:300]}", flush=True) + + # 写入原始 chunk 到日志文件 + with open("/tmp/ds2api_debug.log", "a") as f: + f.write(f"[MAIN] chunk_path={chunk.get('p', '')}, v_type={type(chunk.get('v')).__name__}, chunk={str(chunk)[:300]}\n") if "v" in chunk: v_value = chunk["v"] @@ -181,6 +186,23 @@ async def chat_completions(request: Request): if chunk_path == "response/search_status": continue + # 跳过所有状态相关的 chunk(不是内容) + # 注意:response/status 是真正的结束信号,需要特殊处理(后面的代码会处理) + # 但 response/fragments/-1/status 等需要跳过 + skip_patterns = [ + "quasi_status", "elapsed_secs", "token_usage", + "pending_fragment", "conversation_mode", + "fragments/-1/status", "fragments/-2/status" # 搜索片段状态 + ] + if any(kw in chunk_path for kw in skip_patterns): + continue + + # 检查是否是真正的响应结束信号 + if chunk_path == "response/status" and isinstance(v_value, str) and v_value == "FINISHED": + result_queue.put({"choices": [{"index": 0, "finish_reason": "stop"}]}) + result_queue.put(None) + return + # 检测是否开始正式回复 # 只有当 fragments 包含 RESPONSE 类型时才认为开始正式回复 if "response/fragments" in chunk_path and isinstance(v_value, list): @@ -207,10 +229,12 @@ async def chat_completions(request: Request): else: ptype = "text" - # logger.info(f"[sse_stream] ptype={ptype}, response_started={response_started}, chunk_path='{chunk_path}', v_type={type(v_value).__name__}, v={str(v_value)[:50]}") + logger.info(f"[sse_stream] ptype={ptype}, response_started={response_started}, chunk_path='{chunk_path}', v_type={type(v_value).__name__}, v={str(v_value)[:100]}") if isinstance(v_value, str): # 检查是否是 FINISHED 状态 - if v_value == "FINISHED": + # 只有当 chunk_path 为空或为 "status" 时才认为是真正的结束 + # 搜索模型会发送 "response/fragments/-1/status": "FINISHED" 表示搜索片段完成,不是响应结束 + if v_value == "FINISHED" and (not chunk_path or chunk_path == "status"): result_queue.put({"choices": [{"index": 0, "finish_reason": "stop"}]}) result_queue.put(None) return @@ -227,21 +251,51 @@ async def chat_completions(request: Request): if not isinstance(item, dict): continue - # 检查是否是 FINISHED 状态 - if item.get("p") == "status" and item.get("v") == "FINISHED": - return None # 信号结束 - item_p = item.get("p", "") item_v = item.get("v") + # 写入调试日志 - 显示完整的 item + with open("/tmp/ds2api_debug.log", "a") as f: + f.write(f"[extract] full_item={str(item)[:200]}\n") + + # 跳过搜索结果项(包含 url/title/snippet 的项目) + if "url" in item and "title" in item: + continue + + # 跳过 quasi_status(搜索完成信号,不是响应完成) + if item_p == "quasi_status": + continue + + # 跳过 accumulated_token_usage 和 has_pending_fragment + if item_p in ("accumulated_token_usage", "has_pending_fragment"): + continue + + # 只有当 p="status" (精确匹配) 且 v="FINISHED" 才认为是真正结束 + if item_p == "status" and item_v == "FINISHED": + return None # 信号结束 + # 跳过搜索状态 if item_p == "response/search_status": continue - # 确定类型 + # 直接处理包含 content 和 type 的项 (例如 {'id': 2, 'type': 'RESPONSE', 'content': '...'}) + if "content" in item and "type" in item: + inner_type = item.get("type", "").upper() + if inner_type == "THINK" or inner_type == "THINKING": + final_type = "thinking" + elif inner_type == "RESPONSE": + final_type = "text" + else: + final_type = default_type + content = item.get("content", "") + if content: + extracted.append((content, final_type)) + continue + + # 确定类型(基于 p 字段) if "thinking" in item_p: content_type = "thinking" - elif "content" in item_p or item_p == "response": + elif "content" in item_p or item_p == "response" or item_p == "fragments": content_type = "text" else: content_type = default_type @@ -256,7 +310,6 @@ async def chat_completions(request: Request): if isinstance(inner, dict): # 检查内层的 type 字段 inner_type = inner.get("type", "").upper() - # logger.info(f"[sse_stream] 内层 type={inner_type}, content={str(inner.get('content', ''))[:50]}") # DeepSeek 使用 THINK 而不是 THINKING if inner_type == "THINK" or inner_type == "THINKING": final_type = "thinking" @@ -396,7 +449,8 @@ async def chat_completions(request: Request): if ctype == "thinking": if thinking_enabled: final_thinking += ctext - elif ctype == "text": + else: + # 非 thinking 内容都作为普通文本处理(包括 ctype=None 或 "text") final_text += ctext delta_obj = {} if not first_chunk_sent: @@ -405,8 +459,10 @@ async def chat_completions(request: Request): if ctype == "thinking": if thinking_enabled: delta_obj["reasoning_content"] = ctext - elif ctype == "text": - delta_obj["content"] = ctext + else: + # 非 thinking 内容都作为 content 输出 + if ctext: + delta_obj["content"] = ctext if delta_obj: new_choices.append({"delta": delta_obj, "index": choice.get("index", 0)}) diff --git a/webui/src/App.jsx b/webui/src/App.jsx index c58fafb..456b8a2 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -133,7 +133,7 @@ export default function App() {
-

检查登录状态...

+

正在检查登录状态...

) @@ -184,7 +184,7 @@ export default function App() { DS2API -

V1.0.0 Admin Panel

+

V1.0.0 管理面板