diff --git a/README.MD b/README.MD index 412596e..94fa884 100644 --- a/README.MD +++ b/README.MD @@ -4,11 +4,14 @@ # DS2API +CJackHwang%2Fds2api | Trendshift + [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![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)](docs/DEPLOY.md) + [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/CJackHwang/ds2api) @@ -317,9 +320,9 @@ Gemini 路由还可以使用 `x-goog-api-key`,或在没有认证头时使用 ` 4. `responses` 支持并执行 `tool_choice`(`auto`/`none`/`required`/强制函数);`required` 违规时非流式返回 `422`,流式返回 `response.failed` 5. 客户端请求哪种协议,就按该协议返回工具调用(OpenAI/Claude/Gemini 各自原生结构);模型侧优先约束输出规范 XML,再由兼容层转译 -> 说明:当前版本在 parser 层仍以“尽量解析成功”为优先,未启用基于 allow-list 的工具名硬拒绝。 +> 说明:当前版本 parser 层以”尽量解析成功”为优先,所有格式合法的 XML 工具调用都会通过,不做工具名 allow-list 过滤。 > -> 想评估“把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。 +> 想评估”把工具调用封装成 XML 再输入模型”的方案,可参考:`docs/toolcall-semantics.md`。 ## 本地开发抓包工具 diff --git a/README.en.md b/README.en.md index 747993d..d267b82 100644 --- a/README.en.md +++ b/README.en.md @@ -4,6 +4,8 @@ # DS2API +CJackHwang%2Fds2api | Trendshift + [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) ![Stars](https://img.shields.io/github/stars/CJackHwang/ds2api.svg) ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) diff --git a/VERSION b/VERSION index fcdb2e1..1454f6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.0 +4.0.1 diff --git a/internal/toolstream/tool_sieve_core.go b/internal/toolstream/tool_sieve_core.go index 2ec0914..3f77b8e 100644 --- a/internal/toolstream/tool_sieve_core.go +++ b/internal/toolstream/tool_sieve_core.go @@ -193,5 +193,8 @@ func consumeToolCapture(state *State, toolNames []string) (prefix string, calls if hasOpenXMLToolTag(captured) { return "", nil, "", false } - return "", nil, "", false + if shouldKeepBareInvokeCapture(captured) { + return "", nil, "", false + } + return captured, nil, "", true } diff --git a/internal/toolstream/tool_sieve_xml.go b/internal/toolstream/tool_sieve_xml.go index 72cbbaa..b963981 100644 --- a/internal/toolstream/tool_sieve_xml.go +++ b/internal/toolstream/tool_sieve_xml.go @@ -90,6 +90,38 @@ func hasOpenXMLToolTag(captured string) bool { return false } +func shouldKeepBareInvokeCapture(captured string) bool { + lower := strings.ToLower(captured) + invokeIdx := strings.Index(lower, "", invokeIdx) > invokeIdx { + return true + } + + startEnd := findXMLTagEnd(captured, invokeIdx+len("", startEnd+1) + if invokeCloseIdx >= 0 { + afterClose := captured[invokeCloseIdx+len(""):] + return strings.TrimSpace(afterClose) == "" + } + + trimmedLower := strings.ToLower(trimmedBody) + return strings.HasPrefix(trimmedLower, "' { + return i + } + } + return -1 +} + // findPartialXMLToolTagStart checks if the string ends with a partial canonical // XML wrapper tag (e.g., "` as plain documentation text." + events := ProcessChunk(&state, chunk, []string{"read_file"}) + + var textContent strings.Builder + toolCalls := 0 + for _, evt := range events { + textContent.WriteString(evt.Content) + toolCalls += len(evt.ToolCalls) + } + + if toolCalls != 0 { + t.Fatalf("expected inline invoke prose to remain text, got %d events=%#v", toolCalls, events) + } + if textContent.String() != chunk { + t.Fatalf("expected inline invoke prose to stream immediately, got %q", textContent.String()) + } + if state.capturing { + t.Fatal("expected inline invoke prose not to leave stream capture open") + } +} + +func TestProcessToolSieveBareInvokeExampleReleasesWhenNotRepairable(t *testing.T) { + var state State + chunks := []string{ + `Example: README.md`, + " then continue.", + } + var events []Event + for _, c := range chunks { + events = append(events, ProcessChunk(&state, c, []string{"read_file"})...) + } + + var textContent strings.Builder + toolCalls := 0 + for _, evt := range events { + textContent.WriteString(evt.Content) + toolCalls += len(evt.ToolCalls) + } + + if toolCalls != 0 { + t.Fatalf("expected non-repairable bare invoke to remain text, got %d events=%#v", toolCalls, events) + } + if textContent.String() != strings.Join(chunks, "") { + t.Fatalf("expected non-repairable bare invoke to pass through, got %q", textContent.String()) + } + if state.capturing { + t.Fatal("expected non-repairable bare invoke not to leave stream capture open") + } +} + func TestProcessToolSieveRepairsMissingOpeningWrapperWithoutLeakingInvokeText(t *testing.T) { var state State chunks := []string{