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:
+
+[](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 上一键部署:
+
+[](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
[](LICENSE)
@@ -5,6 +9,7 @@

[](https://github.com/CJackHwang/ds2api/releases)
[](DEPLOY.md)
+[](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
[](LICENSE)
@@ -5,6 +9,7 @@

[](https://github.com/CJackHwang/ds2api/releases)
[](DEPLOY.en.md)
+[](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