修复裸 invoke 标签导致流式输出卡住的问题

当模型输出不带 <tool_calls> 包裹的裸 <invoke> 标签时(如文档示例或行内引用),
之前的逻辑会一直等待关闭标签导致流式输出卡住。现在通过 shouldKeepBareInvokeCapture
判断是否为可修复的调用,不可修复的直接作为文本释放。

同时更新 README 文档中工具调用解析说明,并移除 allow-list 过滤的过时描述。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
CJACK
2026-04-26 11:14:26 +08:00
parent f6df01d3aa
commit 3627c7366d
6 changed files with 117 additions and 4 deletions

View File

@@ -4,11 +4,14 @@
# DS2API
<a href="https://trendshift.io/repositories/24508" target="_blank"><img src="https://trendshift.io/api/badge/repositories/24508" alt="CJackHwang%2Fds2api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![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`。
## 本地开发抓包工具

View File

@@ -4,6 +4,8 @@
# DS2API
<a href="https://trendshift.io/repositories/24508" target="_blank"><img src="https://trendshift.io/api/badge/repositories/24508" alt="CJackHwang%2Fds2api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![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)

View File

@@ -1 +1 @@
4.0.0
4.0.1

View File

@@ -193,5 +193,8 @@ func consumeToolCapture(state *State, toolNames []string) (prefix string, calls
if hasOpenXMLToolTag(captured) {
return "", nil, "", false
}
if shouldKeepBareInvokeCapture(captured) {
return "", nil, "", false
}
return captured, nil, "", true
}

View File

@@ -90,6 +90,38 @@ func hasOpenXMLToolTag(captured string) bool {
return false
}
func shouldKeepBareInvokeCapture(captured string) bool {
lower := strings.ToLower(captured)
invokeIdx := strings.Index(lower, "<invoke")
if invokeIdx < 0 || strings.Contains(lower, "<tool_calls") {
return false
}
if findXMLCloseOutsideCDATA(captured, "</tool_calls>", invokeIdx) > invokeIdx {
return true
}
startEnd := findXMLTagEnd(captured, invokeIdx+len("<invoke"))
if startEnd < 0 {
return true
}
body := captured[startEnd+1:]
trimmedBody := strings.TrimLeft(body, " \t\r\n")
if trimmedBody == "" {
return true
}
invokeCloseIdx := findXMLCloseOutsideCDATA(captured, "</invoke>", startEnd+1)
if invokeCloseIdx >= 0 {
afterClose := captured[invokeCloseIdx+len("</invoke>"):]
return strings.TrimSpace(afterClose) == ""
}
trimmedLower := strings.ToLower(trimmedBody)
return strings.HasPrefix(trimmedLower, "<parameter") ||
strings.HasPrefix(trimmedLower, "{") ||
strings.HasPrefix(trimmedLower, "[")
}
func findXMLCloseOutsideCDATA(s, closeTag string, start int) int {
if s == "" || closeTag == "" {
return -1
@@ -122,6 +154,27 @@ func findXMLCloseOutsideCDATA(s, closeTag string, start int) int {
return -1
}
func findXMLTagEnd(s string, start int) int {
quote := byte(0)
for i := start; i < len(s); i++ {
ch := s[i]
if quote != 0 {
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '>' {
return i
}
}
return -1
}
// findPartialXMLToolTagStart checks if the string ends with a partial canonical
// XML wrapper tag (e.g., "<too") and returns the position of the '<'.
func findPartialXMLToolTagStart(s string) int {

View File

@@ -567,6 +567,58 @@ func TestProcessToolSievePassesThroughBareToolCallAsText(t *testing.T) {
}
}
func TestProcessToolSieveBareInvokeInlineProseDoesNotStall(t *testing.T) {
var state State
chunk := "Use `<invoke name=\"read_file\">` 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: <invoke name="read_file"><parameter name="path">README.md</parameter>`,
"</invoke> 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{