diff --git a/README.MD b/README.MD index 0ce3ec7..d3dc05d 100644 --- a/README.MD +++ b/README.MD @@ -106,6 +106,14 @@ flowchart LR 可通过配置中的 `claude_mapping` 或 `claude_model_mapping` 覆盖映射关系。 另外,`/anthropic/v1/models` 现已包含 Claude 1.x/2.x/3.x/4.x 历史模型 ID 与常见别名,便于旧客户端直接兼容。 + +#### Claude Code 接入避坑(实测) + +- `ANTHROPIC_BASE_URL` 推荐直接指向 DS2API 根地址(例如 `http://127.0.0.1:5001`),Claude Code 会请求 `/v1/messages?beta=true`。 +- `ANTHROPIC_API_KEY` 需要与 `config.json` 中 `keys` 一致;建议同时保留常规 key 与 `sk-ant-*` 形态 key,兼容不同客户端校验习惯。 +- 若系统设置了代理,建议对 DS2API 地址配置 `NO_PROXY=127.0.0.1,localhost,<你的主机IP>`,避免本地回环请求被代理拦截。 +- 如遇“工具调用输出成文本、未执行”问题,请升级到包含 Claude 工具调用多格式解析(JSON/XML/ANTML/invoke)的版本。 + ### Gemini 接口 Gemini 适配器将模型名通过 `model_aliases` 或内置规则映射到 DeepSeek 原生模型,支持 `generateContent` 和 `streamGenerateContent` 两种调用方式,并完整支持 Tool Calling(`functionDeclarations` → `functionCall` 输出)。 diff --git a/README.en.md b/README.en.md index 72d8bd8..1c07c23 100644 --- a/README.en.md +++ b/README.en.md @@ -106,6 +106,14 @@ flowchart LR Override mapping via `claude_mapping` or `claude_model_mapping` in config. In addition, `/anthropic/v1/models` now includes historical Claude 1.x/2.x/3.x/4.x IDs and common aliases for legacy client compatibility. + +#### Claude Code integration pitfalls (validated) + +- Set `ANTHROPIC_BASE_URL` to the DS2API root URL (for example `http://127.0.0.1:5001`). Claude Code sends requests to `/v1/messages?beta=true`. +- `ANTHROPIC_API_KEY` must match an entry in `keys` from `config.json`. Keeping both a regular key and an `sk-ant-*` style key improves client compatibility. +- If your environment has proxy variables, set `NO_PROXY=127.0.0.1,localhost,` for DS2API to avoid proxy interception of local traffic. +- If tool calls are rendered as plain text and not executed, upgrade to a build that includes multi-format Claude tool-call parsing (JSON/XML/ANTML/invoke). + ### Gemini Endpoint The Gemini adapter maps model names to DeepSeek native models via `model_aliases` or built-in heuristics, supporting both `generateContent` and `streamGenerateContent` call patterns with full Tool Calling support (`functionDeclarations` → `functionCall` output). diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index f5b0ad5..b6c009a 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -125,8 +125,11 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "Search the web") { t.Fatalf("expected description in prompt") } - if !containsStr(prompt, "tool_calls") { - t.Fatalf("expected tool_calls instruction in prompt") + if !containsStr(prompt, "tool_use") { + t.Fatalf("expected tool_use instruction in prompt") + } + if containsStr(prompt, "tool_calls") { + t.Fatalf("expected prompt to avoid tool_calls JSON instruction") } } diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index 3728ffb..2f0c08a 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -51,7 +51,7 @@ func buildClaudeToolPrompt(tools []any) string { parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) } parts = append(parts, - "When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}", + "When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.", "History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.", "After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.", ) diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/adapter/claude/stream_runtime_core.go index cb24bdd..6bd8e94 100644 --- a/internal/adapter/claude/stream_runtime_core.go +++ b/internal/adapter/claude/stream_runtime_core.go @@ -8,6 +8,7 @@ import ( "ds2api/internal/sse" streamengine "ds2api/internal/stream" + "ds2api/internal/util" ) type claudeStreamRuntime struct { @@ -116,6 +117,15 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse s.text.WriteString(p.Text) if s.bufferToolContent { + detected := util.ParseToolCalls(s.text.String(), s.toolNames) + if len(detected) > 0 { + s.finalize("tool_use") + return streamengine.ParsedDecision{ + ContentSeen: true, + Stop: true, + StopReason: streamengine.StopReason("tool_use_detected"), + } + } continue } s.closeThinkingBlock() diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 6e949b1..2d9034a 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -2,11 +2,19 @@ package util import ( "encoding/json" + "encoding/xml" "regexp" "strings" ) var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`) +var xmlToolCallPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) +var functionCallPattern = regexp.MustCompile(`(?is)\s*([^<]+?)\s*`) +var functionParamPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) +var antmlFunctionCallPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?function_call[^>]*name="([^"]+)"[^>]*>\s*(.*?)\s*`) +var antmlArgumentPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_]+:)?argument\s+name="([^"]+)"\s*>\s*(.*?)\s*`) +var invokeCallPattern = regexp.MustCompile(`(?is)(.*?)`) +var invokeParamPattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) type ParsedToolCall struct { Name string `json:"name"` @@ -45,7 +53,11 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa } } if len(parsed) == 0 { - return result + parsed = parseXMLToolCalls(text) + if len(parsed) == 0 { + return result + } + result.SawToolCallSyntax = true } calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) @@ -267,3 +279,168 @@ func parseToolCallInput(v any) map[string]any { return map[string]any{} } } + +func parseXMLToolCalls(text string) []ParsedToolCall { + matches := xmlToolCallPattern.FindAllString(text, -1) + out := make([]ParsedToolCall, 0, len(matches)+1) + for _, block := range matches { + call, ok := parseSingleXMLToolCall(block) + if !ok { + continue + } + out = append(out, call) + } + if len(out) > 0 { + return out + } + if call, ok := parseFunctionCallTagStyle(text); ok { + return []ParsedToolCall{call} + } + if call, ok := parseAntmlFunctionCallStyle(text); ok { + return []ParsedToolCall{call} + } + if call, ok := parseInvokeFunctionCallStyle(text); ok { + return []ParsedToolCall{call} + } + return nil +} + +func parseSingleXMLToolCall(block string) (ParsedToolCall, bool) { + inner := strings.TrimSpace(block) + inner = strings.TrimPrefix(inner, "") + inner = strings.TrimSuffix(inner, "") + inner = strings.TrimSpace(inner) + if strings.HasPrefix(inner, "{") { + var payload map[string]any + if err := json.Unmarshal([]byte(inner), &payload); err == nil { + name := strings.TrimSpace(asString(payload["tool"])) + if name == "" { + name = strings.TrimSpace(asString(payload["tool_name"])) + } + if name != "" { + input := map[string]any{} + if params, ok := payload["params"].(map[string]any); ok { + input = params + } else if params, ok := payload["parameters"].(map[string]any); ok { + input = params + } + return ParsedToolCall{Name: name, Input: input}, true + } + } + } + + dec := xml.NewDecoder(strings.NewReader(block)) + name := "" + params := map[string]any{} + inParams := false + for { + tok, err := dec.Token() + if err != nil { + break + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + switch strings.ToLower(start.Name.Local) { + case "parameters": + inParams = true + case "tool_name", "name": + var v string + if err := dec.DecodeElement(&v, &start); err == nil && strings.TrimSpace(v) != "" { + name = strings.TrimSpace(v) + } + default: + if inParams { + var v string + if err := dec.DecodeElement(&v, &start); err == nil { + params[start.Name.Local] = strings.TrimSpace(v) + } + } + } + } + if strings.TrimSpace(name) == "" { + return ParsedToolCall{}, false + } + return ParsedToolCall{Name: strings.TrimSpace(name), Input: params}, true +} + +func parseFunctionCallTagStyle(text string) (ParsedToolCall, bool) { + m := functionCallPattern.FindStringSubmatch(text) + if len(m) < 2 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + input := map[string]any{} + for _, pm := range functionParamPattern.FindAllStringSubmatch(text, -1) { + if len(pm) < 3 { + continue + } + key := strings.TrimSpace(pm[1]) + val := strings.TrimSpace(pm[2]) + if key != "" { + input[key] = val + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func parseAntmlFunctionCallStyle(text string) (ParsedToolCall, bool) { + m := antmlFunctionCallPattern.FindStringSubmatch(text) + if len(m) < 3 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + body := strings.TrimSpace(m[2]) + input := map[string]any{} + if strings.HasPrefix(body, "{") { + if err := json.Unmarshal([]byte(body), &input); err == nil { + return ParsedToolCall{Name: name, Input: input}, true + } + } + for _, am := range antmlArgumentPattern.FindAllStringSubmatch(body, -1) { + if len(am) < 3 { + continue + } + k := strings.TrimSpace(am[1]) + v := strings.TrimSpace(am[2]) + if k != "" { + input[k] = v + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func parseInvokeFunctionCallStyle(text string) (ParsedToolCall, bool) { + m := invokeCallPattern.FindStringSubmatch(text) + if len(m) < 3 { + return ParsedToolCall{}, false + } + name := strings.TrimSpace(m[1]) + if name == "" { + return ParsedToolCall{}, false + } + input := map[string]any{} + for _, pm := range invokeParamPattern.FindAllStringSubmatch(m[2], -1) { + if len(pm) < 3 { + continue + } + k := strings.TrimSpace(pm[1]) + v := strings.TrimSpace(pm[2]) + if k != "" { + input[k] = v + } + } + return ParsedToolCall{Name: name, Input: input}, true +} + +func asString(v any) string { + s, _ := v.(string) + return s +} diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 0e682dc..f38dbb0 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -137,3 +137,98 @@ func TestParseToolCallsAllowsPunctuationVariantToolName(t *testing.T) { t.Fatalf("expected canonical tool name read_file, got %q", calls[0].Name) } } + +func TestParseToolCallsSupportsClaudeXMLToolCall(t *testing.T) { + text := `Bashpwdshow cwd` + 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) + } + if calls[0].Input["command"] != "pwd" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsDetailedMarksXMLToolCallSyntax(t *testing.T) { + text := `Bashpwd` + res := ParseToolCallsDetailed(text, []string{"bash"}) + if !res.SawToolCallSyntax { + t.Fatalf("expected SawToolCallSyntax=true, got %#v", res) + } + if len(res.Calls) != 1 { + t.Fatalf("expected one parsed call, got %#v", res) + } +} + +func TestParseToolCallsSupportsClaudeXMLJSONToolCall(t *testing.T) { + text := `{"tool":"Bash","params":{"command":"pwd","description":"show cwd"}}` + 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) + } + if calls[0].Input["command"] != "pwd" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsFunctionCallTagStyle(t *testing.T) { + text := `Bashls -lalist` + 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) + } + if calls[0].Input["command"] != "ls -la" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsAntmlFunctionCallStyle(t *testing.T) { + text := `{"command":"pwd","description":"x"}` + 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) + } + if calls[0].Input["command"] != "pwd" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsAntmlArgumentStyle(t *testing.T) { + text := `pwdx` + 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) + } + if calls[0].Input["command"] != "pwd" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) { + text := `pwdd` + 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) + } + if calls[0].Input["command"] != "pwd" { + t.Fatalf("expected command argument, got %#v", calls[0].Input) + } +}