diff --git a/DEPLOY.en.md b/DEPLOY.en.md index 2ff3250..127696a 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -175,6 +175,18 @@ If container logs look normal but the admin panel is unreachable, check these fi 1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`). 2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`. +### 2.7 Zeabur One-Click (Dockerfile) + +This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur: + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) + +Notes: + +- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`. +- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path. +- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login). + --- ## 3. Vercel Deployment diff --git a/DEPLOY.md b/DEPLOY.md index d7d74d3..2df4238 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -175,6 +175,18 @@ healthcheck: 1. **端口是否一致**:`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。 2. **开发 compose 的 WebUI 静态文件**:`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js;若仓库里没有 `static/admin`,`/admin` 会返回 404。可先在宿主机构建一次:`./scripts/build-webui.sh`。 +### 2.7 Zeabur 一键部署(Dockerfile) + +仓库提供 `zeabur.yaml` 模板,可在 Zeabur 上一键部署: + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) + +部署要点: + +- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。 +- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。 +- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。 + --- ## 三、Vercel 部署 diff --git a/README.MD b/README.MD index b8c3be0..9b77d09 100644 --- a/README.MD +++ b/README.MD @@ -1,3 +1,7 @@ +

+ DS2API icon +

+ # DS2API [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) @@ -5,6 +9,7 @@ ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) 语言 / Language: [中文](README.MD) | [English](README.en.md) @@ -162,6 +167,12 @@ docker-compose logs -f 更新镜像:`docker-compose up -d --build` +#### Zeabur 一键部署(Dockerfile) + +1. 点击上方 “Deploy on Zeabur” 按钮,一键部署。 +2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。 +3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。 + ### 方式三:Vercel 部署 1. Fork 仓库到自己的 GitHub diff --git a/README.en.md b/README.en.md index 4e872ad..add018b 100644 --- a/README.en.md +++ b/README.en.md @@ -1,3 +1,7 @@ +

+ DS2API icon +

+ # DS2API [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) @@ -5,6 +9,7 @@ ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) Language: [中文](README.MD) | [English](README.en.md) @@ -162,6 +167,12 @@ docker-compose logs -f Rebuild after updates: `docker-compose up -d --build` +#### Zeabur One-Click (Dockerfile) + +1. Click the “Deploy on Zeabur” button above to deploy. +2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions. +3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`). + ### Option 3: Vercel 1. Fork this repo to your GitHub account diff --git a/assets/ds2api-icon.svg b/assets/ds2api-icon.svg new file mode 100644 index 0000000..faf8eb3 --- /dev/null +++ b/assets/ds2api-icon.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index 701c8d7..ebce879 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -183,6 +183,66 @@ func TestHandleClaudeStreamRealtimeToolSafety(t *testing.T) { } } +func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`, + `data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + foundToolUse := false + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" && contentBlock["name"] == "search" { + foundToolUse = true + break + } + } + if !foundToolUse { + t.Fatalf("expected tool_use block from thinking fallback, body=%s", rec.Body.String()) + } +} + +func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`, + `data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`, + `data: {"p":"response/content","v":"normal answer"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" { + t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String()) + } + } + + foundEndTurn := false + for _, f := range findClaudeFrames(frames, "message_delta") { + delta, _ := f.Payload["delta"].(map[string]any) + if delta["stop_reason"] == "end_turn" { + foundEndTurn = true + break + } + } + if !foundEndTurn { + t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String()) + } +} + func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) { h := &Handler{} resp := makeClaudeSSEHTTPResponse( diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index ae75d8e..f5b0ad5 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -141,6 +141,34 @@ func TestBuildClaudeToolPromptMultipleTools(t *testing.T) { } } +func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) { + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "Search via function tool", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "q": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + prompt := buildClaudeToolPrompt(tools) + if !containsStr(prompt, "Tool: search") { + t.Fatalf("expected OpenAI-style function tool name in prompt, got: %q", prompt) + } + if !containsStr(prompt, "Search via function tool") { + t.Fatalf("expected OpenAI-style function tool description in prompt, got: %q", prompt) + } + if !containsStr(prompt, "\"q\"") { + t.Fatalf("expected parameters schema serialized in prompt, got: %q", prompt) + } +} + func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) { tools := []any{"not a map"} prompt := buildClaudeToolPrompt(tools) @@ -237,6 +265,21 @@ func TestExtractClaudeToolNamesNil(t *testing.T) { } } +func TestExtractClaudeToolNamesSupportsOpenAIStyleFunctionTool(t *testing.T) { + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + }, + }, + } + names := extractClaudeToolNames(tools) + if len(names) != 1 || names[0] != "search" { + t.Fatalf("expected [search], got %v", names) + } +} + // ─── toMessageMaps ─────────────────────────────────────────────────── func TestToMessageMapsNormal(t *testing.T) { diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index df4c6b2..3728ffb 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -46,9 +46,8 @@ func buildClaudeToolPrompt(tools []any) string { if !ok { continue } - name, _ := m["name"].(string) - desc, _ := m["description"].(string) - schema, _ := json.Marshal(m["input_schema"]) + name, desc, schemaObj := extractClaudeToolMeta(m) + schema, _ := json.Marshal(schemaObj) parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) } parts = append(parts, @@ -98,13 +97,43 @@ func extractClaudeToolNames(tools []any) []string { if !ok { continue } - if name, ok := m["name"].(string); ok && name != "" { + name, _, _ := extractClaudeToolMeta(m) + if name != "" { out = append(out, name) } } return out } +func extractClaudeToolMeta(m map[string]any) (string, string, any) { + name, _ := m["name"].(string) + desc, _ := m["description"].(string) + schemaObj := m["input_schema"] + if schemaObj == nil { + schemaObj = m["parameters"] + } + + if fn, ok := m["function"].(map[string]any); ok { + if strings.TrimSpace(name) == "" { + name, _ = fn["name"].(string) + } + if strings.TrimSpace(desc) == "" { + desc, _ = fn["description"].(string) + } + if schemaObj == nil { + if v, ok := fn["input_schema"]; ok { + schemaObj = v + } + } + if schemaObj == nil { + if v, ok := fn["parameters"]; ok { + schemaObj = v + } + } + } + return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj +} + func toMessageMaps(v any) []map[string]any { arr, ok := v.([]any) if !ok { diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index f957ba1..18a9e2d 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -46,6 +46,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { if s.bufferToolContent { detected := util.ParseToolCalls(finalText, s.toolNames) + if len(detected) == 0 && finalText == "" && finalThinking != "" { + detected = util.ParseToolCalls(finalThinking, s.toolNames) + } if len(detected) > 0 { stopReason = "tool_use" for i, tc := range detected { diff --git a/internal/format/claude/render.go b/internal/format/claude/render.go index fdba055..d1a486b 100644 --- a/internal/format/claude/render.go +++ b/internal/format/claude/render.go @@ -9,6 +9,9 @@ import ( func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { detected := util.ParseToolCalls(finalText, toolNames) + if len(detected) == 0 && finalText == "" && finalThinking != "" { + detected = util.ParseToolCalls(finalThinking, toolNames) + } content := make([]map[string]any, 0, 4) if finalThinking != "" { content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking}) diff --git a/internal/format/claude/render_test.go b/internal/format/claude/render_test.go new file mode 100644 index 0000000..38668d4 --- /dev/null +++ b/internal/format/claude/render_test.go @@ -0,0 +1,62 @@ +package claude + +import "testing" + +func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T) { + resp := BuildMessageResponse( + "msg_1", + "claude-sonnet-4-5", + []any{map[string]any{"role": "user", "content": "hi"}}, + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + "", + []string{"search"}, + ) + + if resp["stop_reason"] != "tool_use" { + t.Fatalf("expected stop_reason=tool_use, got=%#v", resp["stop_reason"]) + } + content, _ := resp["content"].([]map[string]any) + if len(content) < 2 { + t.Fatalf("expected thinking + tool_use content blocks, got=%#v", resp["content"]) + } + last := content[len(content)-1] + if last["type"] != "tool_use" { + t.Fatalf("expected last content block tool_use, got=%#v", last["type"]) + } + if last["name"] != "search" { + t.Fatalf("expected tool name search, got=%#v", last["name"]) + } +} + +func TestBuildMessageResponseSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) { + resp := BuildMessageResponse( + "msg_1", + "claude-sonnet-4-5", + []any{map[string]any{"role": "user", "content": "hi"}}, + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + "normal answer", + []string{"search"}, + ) + + if resp["stop_reason"] != "end_turn" { + t.Fatalf("expected stop_reason=end_turn, got=%#v", resp["stop_reason"]) + } + + content, _ := resp["content"].([]map[string]any) + foundText := false + foundTool := false + for _, block := range content { + if block["type"] == "text" && block["text"] == "normal answer" { + foundText = true + } + if block["type"] == "tool_use" { + foundTool = true + } + } + if !foundText { + t.Fatalf("expected text block with finalText, got=%#v", resp["content"]) + } + if foundTool { + t.Fatalf("unexpected tool_use block when finalText exists, got=%#v", resp["content"]) + } +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 5b386c2..fdace8e 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -89,8 +89,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) { allowed := map[string]struct{}{} + allowedCanonical := map[string]string{} for _, name := range availableToolNames { - allowed[name] = struct{}{} + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + allowed[trimmed] = struct{}{} + lower := strings.ToLower(trimmed) + if _, exists := allowedCanonical[lower]; !exists { + allowedCanonical[lower] = trimmed + } } if len(allowed) == 0 { rejectedSet := map[string]struct{}{} @@ -112,10 +121,17 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin if tc.Name == "" { continue } - if _, ok := allowed[tc.Name]; !ok { + matchedName := "" + if _, ok := allowed[tc.Name]; ok { + matchedName = tc.Name + } else if canonical, ok := allowedCanonical[strings.ToLower(tc.Name)]; ok { + matchedName = canonical + } + if matchedName == "" { rejectedSet[tc.Name] = struct{}{} continue } + tc.Name = matchedName if tc.Input == nil { tc.Input = map[string]any{} } diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 0e823c0..1287102 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -46,6 +46,17 @@ func TestParseToolCallsRejectsUnknownToolName(t *testing.T) { } } +func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) { + text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}` + calls := ParseToolCalls(text, []string{"bash"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "bash" { + t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + } +} + func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) { text := `{"tool_calls":[{"name":"unknown","input":{}}]}` res := ParseToolCallsDetailed(text, []string{"search"}) diff --git a/zeabur.yaml b/zeabur.yaml new file mode 100644 index 0000000..8d36cb4 --- /dev/null +++ b/zeabur.yaml @@ -0,0 +1,60 @@ +# yaml-language-server: $schema=https://schema.zeabur.app/template.json +apiVersion: zeabur.com/v1 +kind: Template +metadata: + name: DS2API +spec: + description: DeepSeek Web 对话转 OpenAI/Claude/Gemini 兼容 API(Go 实现,含 WebUI) + tags: + - DeepSeek + - API + - Go + readme: |- + # DS2API (Zeabur) + + ## After deployment + - Admin panel: `/admin` + - Health check: `/healthz` + - Config is persisted at `/data/config.json` (mounted volume) + + ## First-time setup + 1. Open your service URL, then visit `/admin` + 2. Login with `DS2API_ADMIN_KEY` (shown in Zeabur env/instructions) + 3. Import / edit config in Admin UI (saved to `/data/config.json`) + + services: + - name: ds2api + template: GIT + spec: + source: + source: GITHUB + repo: 1139136822 + branch: main + rootDirectory: / + ports: + - id: web + port: 5001 + type: HTTP + volumes: + - id: data + dir: /data + env: + PORT: + default: "5001" + LOG_LEVEL: + default: "INFO" + DS2API_ADMIN_KEY: + default: ${PASSWORD} + expose: true + DS2API_CONFIG_PATH: + default: /data/config.json + instructions: + - title: Admin panel + content: Visit `/admin` on your service URL. + - title: DS2API admin key + content: ${DS2API_ADMIN_KEY} + healthCheck: + type: HTTP + port: web + http: + path: /healthz