diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..aeb2f81 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,80 @@ +linters-settings: + govet: + check-shadowing: true + golint: + min-confidence: 0 + gocyclo: + min-complexity: 15 + maligned: + suggest-new: true + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 2 + misspell: + locale: US + lll: + line-length: 140 + goimports: + local-prefixes: ds2api + unused: + check-exported: false + unparam: + check-exported: false + nakedret: + max-func-lines: 30 + prealloc: + simple: true + range-loops: true + for-loops: false + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - wrapperFunc + - rangeValCopy + - hugeParam + +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - gosimple + - structcheck + - varcheck + - ineffassign + - deadcode + - typecheck + - bodyclose + - stylecheck + - revive + - unconvert + - goconst + - gocyclo + - asciicheck + - gofmt + - misspell + - nakedret + - exportloopref + - dogsled + +issues: + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "ST1000: at least one file in a package should have a package comment" + +run: + timeout: 5m + tests: true + skip-dirs: + - vendor + - webui/node_modules diff --git a/README.MD b/README.MD index 6fbde90..d0c003a 100644 --- a/README.MD +++ b/README.MD @@ -81,7 +81,7 @@ flowchart LR - **统一路由内核**:所有协议入口统一汇聚到 `internal/server/router.go`,并在同一路由树中注册 OpenAI / Claude / Gemini / Admin / WebUI 路由,避免多入口行为漂移。 - **统一执行链路**:Claude / Gemini 入口先经 `internal/translatorcliproxy` 做协议转换,再进入 `openai.ChatCompletions` 统一处理工具调用与流式语义,最后再转换回原协议响应。 - **适配器分层更清晰**:`internal/adapter/{claude,gemini}` 负责入口/出口协议封装,`internal/adapter/openai` 负责核心执行,DeepSeek 侧调用只保留在 OpenAI 内核中。 -- **Tool Calling 双运行时对齐**:Go 侧(`internal/util`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。 +- **Tool Calling 双运行时对齐**:Go 侧(`internal/toolcall`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义,覆盖 JSON / XML / invoke / text-kv 多风格输入。 - **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。 - **流式能力升级**:`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。 - **可观测与可运维增强**:`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。 @@ -466,7 +466,8 @@ ds2api/ │ ├── stream/ # 统一流式消费引擎 │ ├── testsuite/ # 端到端测试框架与用例编排 │ ├── translatorcliproxy/ # CLIProxy 桥接与流写入组件 -│ ├── util/ # 通用工具函数 +│ ├── toolcall/ # Tool Call 解析、修复与格式化(核心业务逻辑) +│ ├── util/ # 通用工具函数(Token 估算、JSON 辅助等) │ ├── version/ # 版本解析 / 比较与 tag 规范化 │ └── webui/ # WebUI 静态文件托管与自动构建 ├── webui/ # React WebUI 源码(Vite + Tailwind) @@ -543,7 +544,7 @@ npm ci --prefix webui && npm run build --prefix webui go test ./... # 运行 tool calls 相关测试(调试工具调用问题) -go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/ # 运行端到端测试 ./tests/scripts/run-live.sh diff --git a/README.en.md b/README.en.md index a944a9c..5959041 100644 --- a/README.en.md +++ b/README.en.md @@ -81,7 +81,7 @@ flowchart LR - **Unified routing core**: all protocol entries are now centralized through `internal/server/router.go`, with OpenAI / Claude / Gemini / Admin / WebUI routes registered in one tree to avoid multi-entry drift. - **Unified execution chain**: Claude/Gemini entries are translated by `internal/translatorcliproxy`, then executed through `openai.ChatCompletions` for shared tool-calling and stream semantics, then translated back to the client protocol. - **Cleaner adapter boundaries**: `internal/adapter/{claude,gemini}` handles protocol wrappers, while `internal/adapter/openai` remains the execution core; upstream DeepSeek calls are retained only in the OpenAI core. -- **Tool-calling parity across runtimes**: Go (`internal/util`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs. +- **Tool-calling parity across runtimes**: Go (`internal/toolcall`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) follow aligned parsing/anti-leak semantics across JSON / XML / invoke / text-kv inputs. - **Config/runtime separation**: static config (`config`) and runtime policy (`settings`) are managed independently via Admin APIs, enabling hot updates and password rotation with JWT invalidation. - **Streaming behavior upgrade**: `/v1/responses` and `/v1/chat/completions` now share a more consistent incremental tool-call emission strategy across SDK ecosystems. - **Improved operability**: `/healthz`, `/readyz`, `/admin/version`, and `/admin/dev/captures` form a tighter post-deploy diagnostics loop. @@ -464,7 +464,8 @@ ds2api/ │ ├── stream/ # Unified stream consumption engine │ ├── testsuite/ # End-to-end testsuite framework and case orchestration │ ├── translatorcliproxy/ # CLIProxy bridge and stream writer components -│ ├── util/ # Common utilities +│ ├── toolcall/ # Tool Call parsing, repair, and formatting (core business logic) +│ ├── util/ # Common utilities (Token estimation, JSON helpers, etc.) │ ├── version/ # Version parsing/comparison and tag normalization │ └── webui/ # WebUI static file serving and auto-build ├── webui/ # React WebUI source (Vite + Tailwind) diff --git a/VERSION b/VERSION index 94ff29c..6ebad14 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.1 +3.1.2 \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md index f56fb3a..a2cfddd 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -180,10 +180,10 @@ go test ./... ```bash # 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题) -go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/ # 运行单个测试用例 -go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ +go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/ # 运行 format 相关测试 go test -v ./internal/format/... @@ -198,13 +198,13 @@ go test -v ./internal/adapter/openai/... ```bash # 1. 运行 tool calls 相关的所有测试 -go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/toolcall/ # 2. 查看测试输出中的详细调试信息 -go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1 +go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/toolcall/ 2>&1 # 3. 检查具体测试用例的修复效果 -# 测试用例位于 internal/util/toolcalls_test.go,包含: +# 测试用例位于 internal/toolcall/toolcalls_test.go,包含: # - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出 # - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复 # - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理 diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index fef1194..c9283bf 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -1,12 +1,12 @@ package claude import ( + "ds2api/internal/toolcall" "encoding/json" "fmt" "strings" "ds2api/internal/prompt" - "ds2api/internal/util" ) func normalizeClaudeMessages(messages []any) []any { @@ -98,7 +98,7 @@ func buildClaudeToolPrompt(tools []any) string { } return "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\n" + - util.BuildToolCallInstructions(names) + toolcall.BuildToolCallInstructions(names) } func formatClaudeToolResultForPrompt(block map[string]any) string { diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index 50655bd..a8cba05 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -1,6 +1,7 @@ package claude import ( + "ds2api/internal/toolcall" "encoding/json" "fmt" "time" @@ -46,9 +47,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) if s.bufferToolContent { - detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) + detected := toolcall.ParseStandaloneToolCalls(finalText, s.toolNames) if len(detected) == 0 && finalText == "" && finalThinking != "" { - detected = util.ParseStandaloneToolCalls(finalThinking, s.toolNames) + detected = toolcall.ParseStandaloneToolCalls(finalThinking, s.toolNames) } if len(detected) > 0 { stopReason = "tool_use" diff --git a/internal/adapter/gemini/handler_generate.go b/internal/adapter/gemini/handler_generate.go index f662d66..0900121 100644 --- a/internal/adapter/gemini/handler_generate.go +++ b/internal/adapter/gemini/handler_generate.go @@ -1,6 +1,7 @@ package gemini import ( + "ds2api/internal/toolcall" "bytes" "encoding/json" "io" @@ -186,9 +187,9 @@ func buildGeminiUsage(finalPrompt, finalThinking, finalText string, outputTokens } func buildGeminiPartsFromFinal(finalText, finalThinking string, toolNames []string) []map[string]any { - detected := util.ParseToolCalls(finalText, toolNames) + detected := toolcall.ParseToolCalls(finalText, toolNames) if len(detected) == 0 && finalThinking != "" { - detected = util.ParseToolCalls(finalThinking, toolNames) + detected = toolcall.ParseToolCalls(finalThinking, toolNames) } if len(detected) > 0 { parts := make([]map[string]any, 0, len(detected)) diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 6582e09..a199882 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -1,6 +1,7 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "net/http" "strings" @@ -8,7 +9,6 @@ import ( openaifmt "ds2api/internal/format/openai" "ds2api/internal/sse" streamengine "ds2api/internal/stream" - "ds2api/internal/util" ) type chatStreamRuntime struct { @@ -102,7 +102,7 @@ func (s *chatStreamRuntime) sendDone() { func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := cleanVisibleOutput(s.text.String(), s.stripReferenceMarkers) - detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) + detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" delta := map[string]any{ diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go index c11a3c7..44eb4d1 100644 --- a/internal/adapter/openai/handler_toolcall_format.go +++ b/internal/adapter/openai/handler_toolcall_format.go @@ -1,6 +1,7 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "fmt" "strings" @@ -75,7 +76,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh // buildToolCallInstructions delegates to the shared util implementation. func buildToolCallInstructions(toolNames []string) string { - return util.BuildToolCallInstructions(toolNames) + return toolcall.BuildToolCallInstructions(toolNames) } func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any { @@ -138,7 +139,7 @@ func filterIncrementalToolCallDeltasByAllowed(deltas []toolCallDelta, allowedNam return out } -func formatFinalStreamToolCallsWithStableIDs(calls []util.ParsedToolCall, ids map[int]string) []map[string]any { +func formatFinalStreamToolCallsWithStableIDs(calls []toolcall.ParsedToolCall, ids map[int]string) []map[string]any { if len(calls) == 0 { return nil } diff --git a/internal/adapter/openai/responses_handler.go b/internal/adapter/openai/responses_handler.go index 3c799e5..8b4677e 100644 --- a/internal/adapter/openai/responses_handler.go +++ b/internal/adapter/openai/responses_handler.go @@ -1,6 +1,7 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "io" "net/http" @@ -119,7 +120,7 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res if writeUpstreamEmptyOutputError(w, sanitizedThinking, sanitizedText, result.ContentFilter) { return } - textParsed := util.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) + textParsed := toolcall.ParseStandaloneToolCallsDetailed(sanitizedText, toolNames) logResponsesToolPolicyRejection(traceID, toolChoice, textParsed, "text") callCount := len(textParsed.Calls) @@ -200,7 +201,7 @@ func (h *Handler) handleResponsesStream(w http.ResponseWriter, r *http.Request, }) } -func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed util.ToolCallParseResult, channel string) { +func logResponsesToolPolicyRejection(traceID string, policy util.ToolChoicePolicy, parsed toolcall.ToolCallParseResult, channel string) { rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames) if !parsed.RejectedByPolicy || len(rejected) == 0 { return diff --git a/internal/adapter/openai/responses_stream_runtime_core.go b/internal/adapter/openai/responses_stream_runtime_core.go index 5a17dd4..8072ccb 100644 --- a/internal/adapter/openai/responses_stream_runtime_core.go +++ b/internal/adapter/openai/responses_stream_runtime_core.go @@ -1,6 +1,7 @@ package openai import ( + "ds2api/internal/toolcall" "net/http" "strings" @@ -107,7 +108,7 @@ func (s *responsesStreamRuntime) finalize() { s.processToolStreamEvents(flushToolSieve(&s.sieve, s.toolNames), true) } - textParsed := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) + textParsed := toolcall.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) detected := textParsed.Calls s.logToolPolicyRejections(textParsed) @@ -163,8 +164,8 @@ func (s *responsesStreamRuntime) finalize() { s.sendDone() } -func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed util.ToolCallParseResult) { - logRejected := func(parsed util.ToolCallParseResult, channel string) { +func (s *responsesStreamRuntime) logToolPolicyRejections(textParsed toolcall.ToolCallParseResult) { + logRejected := func(parsed toolcall.ToolCallParseResult, channel string) { rejected := filteredRejectedToolNamesForLog(parsed.RejectedToolNames) if !parsed.RejectedByPolicy || len(rejected) == 0 { return diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls.go b/internal/adapter/openai/responses_stream_runtime_toolcalls.go index ebd8004..0e1188e 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls.go +++ b/internal/adapter/openai/responses_stream_runtime_toolcalls.go @@ -1,11 +1,11 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "strings" openaifmt "ds2api/internal/format/openai" - "ds2api/internal/util" "github.com/google/uuid" ) @@ -208,7 +208,7 @@ func (s *responsesStreamRuntime) emitFunctionCallDeltaEvents(deltas []toolCallDe } } -func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []util.ParsedToolCall) { +func (s *responsesStreamRuntime) emitFunctionCallDoneEvents(calls []toolcall.ParsedToolCall) { for idx, tc := range calls { if strings.TrimSpace(tc.Name) == "" { continue diff --git a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go b/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go index d3348d7..249ad22 100644 --- a/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go +++ b/internal/adapter/openai/responses_stream_runtime_toolcalls_finalize.go @@ -1,12 +1,12 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "sort" "strings" openaifmt "ds2api/internal/format/openai" - "ds2api/internal/util" ) func (s *responsesStreamRuntime) closeIncompleteFunctionItems() { @@ -57,7 +57,7 @@ func (s *responsesStreamRuntime) closeIncompleteFunctionItems() { } } -func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []util.ParsedToolCall) map[string]any { +func (s *responsesStreamRuntime) buildCompletedResponseObject(finalThinking, finalText string, calls []toolcall.ParsedToolCall) map[string]any { type indexedItem struct { index int item map[string]any diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 1b3c975..db8a958 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -3,7 +3,7 @@ package openai import ( "strings" - "ds2api/internal/util" + "ds2api/internal/toolcall" ) func processToolSieveChunk(state *toolStreamSieveState, chunk string, toolNames []string) []toolStreamEvent { @@ -226,7 +226,7 @@ func findToolSegmentStart(s string) int { return start } -func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) { +func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) { captured := state.capture.String() if captured == "" { return "", nil, "", false @@ -267,7 +267,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix } prefixPart := captured[:start] suffixPart := captured[end:] - parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames) + parsed := toolcall.ParseStandaloneToolCallsDetailed(obj, toolNames) if len(parsed.Calls) == 0 { if parsed.SawToolCallSyntax && parsed.RejectedByPolicy { // Parsed as tool-call payload but rejected by schema/policy: diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index 6d16878..6bf006b 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -1,9 +1,9 @@ package openai import ( + "ds2api/internal/toolcall" "strings" - "ds2api/internal/util" ) type toolStreamSieveState struct { @@ -12,7 +12,7 @@ type toolStreamSieveState struct { capturing bool recentTextTail string pendingToolRaw string - pendingToolCalls []util.ParsedToolCall + pendingToolCalls []toolcall.ParsedToolCall disableDeltas bool toolNameSent bool toolName string @@ -24,7 +24,7 @@ type toolStreamSieveState struct { type toolStreamEvent struct { Content string - ToolCalls []util.ParsedToolCall + ToolCalls []toolcall.ParsedToolCall ToolCallDeltas []toolCallDelta } diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/adapter/openai/tool_sieve_xml.go index bd1a47d..30e686e 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/adapter/openai/tool_sieve_xml.go @@ -1,10 +1,10 @@ package openai import ( + "ds2api/internal/toolcall" "regexp" "strings" - "ds2api/internal/util" ) // --- XML tool call support for the streaming sieve --- @@ -43,7 +43,7 @@ var xmlToolTagsToDetect = []string{"", " "", "", ""} // consumeXMLToolCapture tries to extract complete XML tool call blocks from captured text. -func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) { +func consumeXMLToolCapture(captured string, toolNames []string) (prefix string, calls []toolcall.ParsedToolCall, suffix string, ready bool) { lower := strings.ToLower(captured) // Find the FIRST matching open/close pair, preferring wrapper tags. // Tag pairs are ordered longest-first (e.g. 0 { prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart) return prefixPart, parsed, suffixPart, true diff --git a/internal/compat/go_compat_test.go b/internal/compat/go_compat_test.go index 5358821..414975d 100644 --- a/internal/compat/go_compat_test.go +++ b/internal/compat/go_compat_test.go @@ -1,6 +1,7 @@ package compat import ( + "ds2api/internal/toolcall" "encoding/json" "os" "path/filepath" @@ -86,22 +87,22 @@ func TestGoCompatToolcallFixtures(t *testing.T) { mustLoadJSON(t, fixturePath, &fixture) var expected struct { - Calls []util.ParsedToolCall `json:"calls"` + Calls []toolcall.ParsedToolCall `json:"calls"` SawToolCallSyntax bool `json:"sawToolCallSyntax"` RejectedByPolicy bool `json:"rejectedByPolicy"` RejectedToolNames []string `json:"rejectedToolNames"` } mustLoadJSON(t, expectedPath, &expected) - var got util.ToolCallParseResult + var got toolcall.ToolCallParseResult switch strings.ToLower(strings.TrimSpace(fixture.Mode)) { case "standalone": - got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames) + got = toolcall.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames) default: - got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames) + got = toolcall.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames) } if got.Calls == nil { - got.Calls = []util.ParsedToolCall{} + got.Calls = []toolcall.ParsedToolCall{} } if got.RejectedToolNames == nil { got.RejectedToolNames = []string{} diff --git a/internal/format/claude/render.go b/internal/format/claude/render.go index d1a486b..4f9ada5 100644 --- a/internal/format/claude/render.go +++ b/internal/format/claude/render.go @@ -1,6 +1,7 @@ package claude import ( + "ds2api/internal/toolcall" "fmt" "time" @@ -8,9 +9,9 @@ import ( ) func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { - detected := util.ParseToolCalls(finalText, toolNames) + detected := toolcall.ParseToolCalls(finalText, toolNames) if len(detected) == 0 && finalText == "" && finalThinking != "" { - detected = util.ParseToolCalls(finalThinking, toolNames) + detected = toolcall.ParseToolCalls(finalThinking, toolNames) } content := make([]map[string]any, 0, 4) if finalThinking != "" { diff --git a/internal/format/openai/render_chat.go b/internal/format/openai/render_chat.go index bdea9b5..8eb54b1 100644 --- a/internal/format/openai/render_chat.go +++ b/internal/format/openai/render_chat.go @@ -1,14 +1,14 @@ package openai import ( + "ds2api/internal/toolcall" "strings" "time" - "ds2api/internal/util" ) func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { - detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames) + detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames) finishReason := "stop" messageObj := map[string]any{"role": "assistant", "content": finalText} if strings.TrimSpace(finalThinking) != "" { @@ -16,7 +16,7 @@ func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalT } if len(detected.Calls) > 0 { finishReason = "tool_calls" - messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls) + messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected.Calls) messageObj["content"] = nil } diff --git a/internal/format/openai/render_responses.go b/internal/format/openai/render_responses.go index a3b37f0..899ce90 100644 --- a/internal/format/openai/render_responses.go +++ b/internal/format/openai/render_responses.go @@ -1,19 +1,19 @@ package openai import ( + "ds2api/internal/toolcall" "encoding/json" "strings" "time" "github.com/google/uuid" - "ds2api/internal/util" ) func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { // Strict mode: only standalone, structured tool-call payloads are treated // as executable tool calls. - detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames) + detected := toolcall.ParseStandaloneToolCallsDetailed(finalText, toolNames) exposedOutputText := finalText output := make([]any, 0, 2) if len(detected.Calls) > 0 { @@ -71,7 +71,7 @@ func BuildResponseObjectFromItems(responseID, model, finalPrompt, finalThinking, } } -func toResponsesFunctionCallItems(toolCalls []util.ParsedToolCall) []any { +func toResponsesFunctionCallItems(toolCalls []toolcall.ParsedToolCall) []any { if len(toolCalls) == 0 { return nil } diff --git a/internal/util/tool_prompt.go b/internal/toolcall/tool_prompt.go similarity index 99% rename from internal/util/tool_prompt.go rename to internal/toolcall/tool_prompt.go index a801286..8d6649a 100644 --- a/internal/util/tool_prompt.go +++ b/internal/toolcall/tool_prompt.go @@ -1,4 +1,4 @@ -package util +package toolcall import "strings" diff --git a/internal/util/tool_prompt_test.go b/internal/toolcall/tool_prompt_test.go similarity index 98% rename from internal/util/tool_prompt_test.go rename to internal/toolcall/tool_prompt_test.go index e10f176..5cfa782 100644 --- a/internal/util/tool_prompt_test.go +++ b/internal/toolcall/tool_prompt_test.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "strings" diff --git a/internal/toolcall/toolcall_edge_test.go b/internal/toolcall/toolcall_edge_test.go new file mode 100644 index 0000000..bf6bc08 --- /dev/null +++ b/internal/toolcall/toolcall_edge_test.go @@ -0,0 +1,92 @@ +package toolcall + +import ( + "testing" +) + +// ─── FormatOpenAIStreamToolCalls ───────────────────────────────────── + +func TestFormatOpenAIStreamToolCalls(t *testing.T) { + formatted := FormatOpenAIStreamToolCalls([]ParsedToolCall{ + {Name: "search", Input: map[string]any{"q": "test"}}, + }) + if len(formatted) != 1 { + t.Fatalf("expected 1, got %d", len(formatted)) + } + fn, _ := formatted[0]["function"].(map[string]any) + if fn["name"] != "search" { + t.Fatalf("unexpected function name: %#v", fn) + } + if formatted[0]["index"] != 0 { + t.Fatalf("expected index 0, got %v", formatted[0]["index"]) + } +} + +// ─── ParseToolCalls more edge cases ────────────────────────────────── + +func TestParseToolCallsNoToolNames(t *testing.T) { + text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` + calls := ParseToolCalls(text, nil) + if len(calls) != 1 { + t.Fatalf("expected 1 call with nil tool names, got %d", len(calls)) + } +} + +func TestParseToolCallsEmptyText(t *testing.T) { + calls := ParseToolCalls("", []string{"search"}) + if len(calls) != 0 { + t.Fatalf("expected 0 calls for empty text, got %d", len(calls)) + } +} + +func TestParseToolCallsMultipleTools(t *testing.T) { + text := `{"tool_calls":[{"name":"search","input":{"q":"go"}},{"name":"get_weather","input":{"city":"beijing"}}]}` + calls := ParseToolCalls(text, []string{"search", "get_weather"}) + if len(calls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(calls)) + } +} + +func TestParseToolCallsInputAsString(t *testing.T) { + text := `{"tool_calls":[{"name":"search","input":"{\"q\":\"golang\"}"}]}` + calls := ParseToolCalls(text, []string{"search"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0].Input["q"] != "golang" { + t.Fatalf("expected parsed string input, got %#v", calls[0].Input) + } +} + +func TestParseToolCallsWithFunctionWrapper(t *testing.T) { + text := `{"tool_calls":[{"function":{"name":"calc","arguments":{"x":1,"y":2}}}]}` + calls := ParseToolCalls(text, []string{"calc"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0].Name != "calc" { + t.Fatalf("expected calc, got %q", calls[0].Name) + } +} + +func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) { + fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this." + calls := ParseStandaloneToolCalls(fenced, []string{"search"}) + if len(calls) != 0 { + t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls)) + } +} + +// ─── looksLikeToolExampleContext ───────────────────────────────────── + +func TestLooksLikeToolExampleContextNone(t *testing.T) { + if looksLikeToolExampleContext("I will call the tool now") { + t.Fatal("expected false for non-example context") + } +} + +func TestLooksLikeToolExampleContextFenced(t *testing.T) { + if !looksLikeToolExampleContext("```json") { + t.Fatal("expected true for fenced code block context") + } +} diff --git a/internal/util/toolcalls_candidates.go b/internal/toolcall/toolcalls_candidates.go similarity index 99% rename from internal/util/toolcalls_candidates.go rename to internal/toolcall/toolcalls_candidates.go index 0c486bf..43c88d0 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/toolcall/toolcalls_candidates.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "regexp" diff --git a/internal/util/toolcalls_format.go b/internal/toolcall/toolcalls_format.go similarity index 98% rename from internal/util/toolcalls_format.go rename to internal/toolcall/toolcalls_format.go index 8feb48f..1ef142b 100644 --- a/internal/util/toolcalls_format.go +++ b/internal/toolcall/toolcalls_format.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "encoding/json" diff --git a/internal/util/toolcalls_input_parse.go b/internal/toolcall/toolcalls_input_parse.go similarity index 99% rename from internal/util/toolcalls_input_parse.go rename to internal/toolcall/toolcalls_input_parse.go index 4f08474..b987e64 100644 --- a/internal/util/toolcalls_input_parse.go +++ b/internal/toolcall/toolcalls_input_parse.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "encoding/json" diff --git a/internal/util/toolcalls_json_repair.go b/internal/toolcall/toolcalls_json_repair.go similarity index 99% rename from internal/util/toolcalls_json_repair.go rename to internal/toolcall/toolcalls_json_repair.go index 6585d79..d5c2ce3 100644 --- a/internal/util/toolcalls_json_repair.go +++ b/internal/toolcall/toolcalls_json_repair.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "regexp" diff --git a/internal/util/toolcalls_markup.go b/internal/toolcall/toolcalls_markup.go similarity index 99% rename from internal/util/toolcalls_markup.go rename to internal/toolcall/toolcalls_markup.go index cc0f8bb..9a6ad2c 100644 --- a/internal/util/toolcalls_markup.go +++ b/internal/toolcall/toolcalls_markup.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "encoding/json" diff --git a/internal/util/toolcalls_name_match.go b/internal/toolcall/toolcalls_name_match.go similarity index 97% rename from internal/util/toolcalls_name_match.go rename to internal/toolcall/toolcalls_name_match.go index c3d1f3f..afa85a0 100644 --- a/internal/util/toolcalls_name_match.go +++ b/internal/toolcall/toolcalls_name_match.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "regexp" diff --git a/internal/util/toolcalls_parse.go b/internal/toolcall/toolcalls_parse.go similarity index 99% rename from internal/util/toolcalls_parse.go rename to internal/toolcall/toolcalls_parse.go index 6127592..b5c5714 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/toolcall/toolcalls_parse.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "encoding/json" diff --git a/internal/util/toolcalls_parse_item.go b/internal/toolcall/toolcalls_parse_item.go similarity index 99% rename from internal/util/toolcalls_parse_item.go rename to internal/toolcall/toolcalls_parse_item.go index fe8ade5..b8909ba 100644 --- a/internal/util/toolcalls_parse_item.go +++ b/internal/toolcall/toolcalls_parse_item.go @@ -1,4 +1,4 @@ -package util +package toolcall import "strings" diff --git a/internal/util/toolcalls_parse_markup.go b/internal/toolcall/toolcalls_parse_markup.go similarity index 99% rename from internal/util/toolcalls_parse_markup.go rename to internal/toolcall/toolcalls_parse_markup.go index a80a870..fa41036 100644 --- a/internal/util/toolcalls_parse_markup.go +++ b/internal/toolcall/toolcalls_parse_markup.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "encoding/json" diff --git a/internal/util/toolcalls_test.go b/internal/toolcall/toolcalls_test.go similarity index 99% rename from internal/util/toolcalls_test.go rename to internal/toolcall/toolcalls_test.go index da78666..663b895 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/toolcall/toolcalls_test.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "strings" diff --git a/internal/util/toolcalls_textkv.go b/internal/toolcall/toolcalls_textkv.go similarity index 98% rename from internal/util/toolcalls_textkv.go rename to internal/toolcall/toolcalls_textkv.go index d487507..fdcac25 100644 --- a/internal/util/toolcalls_textkv.go +++ b/internal/toolcall/toolcalls_textkv.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "regexp" diff --git a/internal/util/toolcalls_textkv_test.go b/internal/toolcall/toolcalls_textkv_test.go similarity index 98% rename from internal/util/toolcalls_textkv_test.go rename to internal/toolcall/toolcalls_textkv_test.go index 91ed807..ed3365a 100644 --- a/internal/util/toolcalls_textkv_test.go +++ b/internal/toolcall/toolcalls_textkv_test.go @@ -1,4 +1,4 @@ -package util +package toolcall import ( "testing" diff --git a/internal/util/render.go b/internal/util/render.go index fff8501..2210bc3 100644 --- a/internal/util/render.go +++ b/internal/util/render.go @@ -1,6 +1,7 @@ package util import ( + "ds2api/internal/toolcall" "fmt" "strings" "time" @@ -11,7 +12,7 @@ import ( // BuildOpenAIChatCompletion is kept for backward compatibility. // Prefer internal/format/openai.BuildChatCompletion for new code. func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { - detected := ParseToolCalls(finalText, toolNames) + detected := toolcall.ParseToolCalls(finalText, toolNames) finishReason := "stop" messageObj := map[string]any{"role": "assistant", "content": finalText} if strings.TrimSpace(finalThinking) != "" { @@ -19,7 +20,7 @@ func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, } if len(detected) > 0 { finishReason = "tool_calls" - messageObj["tool_calls"] = FormatOpenAIToolCalls(detected) + messageObj["tool_calls"] = toolcall.FormatOpenAIToolCalls(detected) messageObj["content"] = nil } promptTokens := EstimateTokens(finalPrompt) @@ -46,7 +47,7 @@ func BuildOpenAIChatCompletion(completionID, model, finalPrompt, finalThinking, // BuildOpenAIResponseObject is kept for backward compatibility. // Prefer internal/format/openai.BuildResponseObject for new code. func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { - detected := ParseToolCalls(finalText, toolNames) + detected := toolcall.ParseToolCalls(finalText, toolNames) exposedOutputText := finalText output := make([]any, 0, 2) if len(detected) > 0 { @@ -56,9 +57,9 @@ func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, fi toolCalls := make([]any, 0, len(detected)) for _, tc := range detected { toolCalls = append(toolCalls, map[string]any{ - "type": "tool_call", - "name": tc.Name, - "arguments": tc.Input, + "type": "tool_call", + "name": tc.Name, + "arguments": tc.Input, }) } output = append(output, map[string]any{ @@ -108,7 +109,7 @@ func BuildOpenAIResponseObject(responseID, model, finalPrompt, finalThinking, fi // BuildClaudeMessageResponse is kept for backward compatibility. // Prefer internal/format/claude.BuildMessageResponse for new code. func BuildClaudeMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { - detected := ParseToolCalls(finalText, toolNames) + detected := toolcall.ParseToolCalls(finalText, toolNames) content := make([]map[string]any, 0, 4) if finalThinking != "" { content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking}) diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 5c1ff94..41d1c9d 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -356,89 +356,3 @@ func TestConvertClaudeToDeepSeekOpusUsesSlowMapping(t *testing.T) { } } -// ─── FormatOpenAIStreamToolCalls ───────────────────────────────────── - -func TestFormatOpenAIStreamToolCalls(t *testing.T) { - formatted := FormatOpenAIStreamToolCalls([]ParsedToolCall{ - {Name: "search", Input: map[string]any{"q": "test"}}, - }) - if len(formatted) != 1 { - t.Fatalf("expected 1, got %d", len(formatted)) - } - fn, _ := formatted[0]["function"].(map[string]any) - if fn["name"] != "search" { - t.Fatalf("unexpected function name: %#v", fn) - } - if formatted[0]["index"] != 0 { - t.Fatalf("expected index 0, got %v", formatted[0]["index"]) - } -} - -// ─── ParseToolCalls more edge cases ────────────────────────────────── - -func TestParseToolCallsNoToolNames(t *testing.T) { - text := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` - calls := ParseToolCalls(text, nil) - if len(calls) != 1 { - t.Fatalf("expected 1 call with nil tool names, got %d", len(calls)) - } -} - -func TestParseToolCallsEmptyText(t *testing.T) { - calls := ParseToolCalls("", []string{"search"}) - if len(calls) != 0 { - t.Fatalf("expected 0 calls for empty text, got %d", len(calls)) - } -} - -func TestParseToolCallsMultipleTools(t *testing.T) { - text := `{"tool_calls":[{"name":"search","input":{"q":"go"}},{"name":"get_weather","input":{"city":"beijing"}}]}` - calls := ParseToolCalls(text, []string{"search", "get_weather"}) - if len(calls) != 2 { - t.Fatalf("expected 2 calls, got %d", len(calls)) - } -} - -func TestParseToolCallsInputAsString(t *testing.T) { - text := `{"tool_calls":[{"name":"search","input":"{\"q\":\"golang\"}"}]}` - calls := ParseToolCalls(text, []string{"search"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(calls)) - } - if calls[0].Input["q"] != "golang" { - t.Fatalf("expected parsed string input, got %#v", calls[0].Input) - } -} - -func TestParseToolCallsWithFunctionWrapper(t *testing.T) { - text := `{"tool_calls":[{"function":{"name":"calc","arguments":{"x":1,"y":2}}}]}` - calls := ParseToolCalls(text, []string{"calc"}) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(calls)) - } - if calls[0].Name != "calc" { - t.Fatalf("expected calc, got %q", calls[0].Name) - } -} - -func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) { - fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this." - calls := ParseStandaloneToolCalls(fenced, []string{"search"}) - if len(calls) != 0 { - t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls)) - } -} - -// ─── looksLikeToolExampleContext ───────────────────────────────────── - -func TestLooksLikeToolExampleContextNone(t *testing.T) { - if looksLikeToolExampleContext("I will call the tool now") { - t.Fatal("expected false for non-example context") - } -} - -func TestLooksLikeToolExampleContextFenced(t *testing.T) { - if !looksLikeToolExampleContext("```json") { - t.Fatal("expected true for fenced code block context") - } -} diff --git a/plans/refactor-line-gate-targets.txt b/plans/refactor-line-gate-targets.txt index 4eed578..e144b75 100644 --- a/plans/refactor-line-gate-targets.txt +++ b/plans/refactor-line-gate-targets.txt @@ -56,9 +56,9 @@ internal/adapter/openai/tool_sieve_core.go internal/adapter/openai/tool_sieve_xml.go internal/adapter/openai/tool_sieve_jsonscan.go -internal/util/toolcalls_parse.go -internal/util/toolcalls_candidates.go -internal/util/toolcalls_format.go +internal/toolcall/toolcalls_parse.go +internal/toolcall/toolcalls_candidates.go +internal/toolcall/toolcalls_format.go internal/adapter/claude/handler_routes.go internal/adapter/claude/handler_messages.go