fix(openai): 解析/截断/续写兼容 DeepSeek DSML 工具调用方言;bump 0.6.5
This commit is contained in:
1 parent
fed260559c
commit
7f7ab190aa
8 files changed
+444
-16
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# dsfree2api — build / test helpers
|
||||
GO ?= go
|
||||
VERSION ?= 0.6.4
|
||||
VERSION ?= 0.6.5
|
||||
LDFLAGS := -s -w -X github.com/nyoungo/dsfree2api/internal/admin.Version=$(VERSION)
|
||||
BIN := bin/dsfree2api
|
||||
IMAGE ?= dsfree2api:latest
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
)
|
||||
|
||||
// Version is reported by the console and /api/overview.
|
||||
var Version = "0.6.4"
|
||||
var Version = "0.6.5"
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
@@ -28,6 +28,9 @@ func ContinuationContext(partial string, head, tail int) string {
|
||||
// caps each generation (~1k tokens), so large tool-call JSON arrives in
|
||||
// pieces; the model must continue byte-for-byte instead of restarting.
|
||||
func ContinueInstruction(partial string, round, maxRounds int) string {
|
||||
if preferDSMLContinue(partial) {
|
||||
return dsmlContinueInstruction(partial, round, maxRounds)
|
||||
}
|
||||
tail := partial
|
||||
if n := len(tail); n > 80 {
|
||||
tail = tail[n-80:]
|
||||
@@ -51,9 +54,36 @@ func ContinueInstruction(partial string, round, maxRounds int) string {
|
||||
deadline, utf8.RuneCountInString(partial), tail)
|
||||
}
|
||||
|
||||
// dsmlContinueInstruction 是 DSML 方言的续写指令:要求模型按原标记格式
|
||||
// 逐字符续写并补齐缺失的闭合标签。XML 内容里的换行是合法字符,因此不套用
|
||||
// JSON 分支的“换行必须写成 \n 转义”规则。
|
||||
func dsmlContinueInstruction(partial string, round, maxRounds int) string {
|
||||
tail := partial
|
||||
if n := len(tail); n > 80 {
|
||||
tail = tail[n-80:]
|
||||
}
|
||||
deadline := fmt.Sprintf("这是第 %d/%d 次续写。", round, maxRounds)
|
||||
if round >= maxRounds {
|
||||
deadline += "这是最后一次续写机会:本轮必须先收束剩余内容(补齐未完成的标记),随后立即闭合全部标签并结束回复,禁止再展开任何新段落。"
|
||||
} else {
|
||||
deadline += "每轮只能输出约 2300 字符;若主体已完成或接近完成,请立即补齐缺失的闭合标签收尾,不要无限扩展。"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"上一条 assistant 回复因通道输出上限被截断(上面保留了它的开头与最新末尾,中间已省略),%s目前已输出约 %d 个字符,最后 80 个字符是:\n%s\n\n"+
|
||||
"请只输出【续写片段】:从上面末尾片段的最后一个字符之后开始逐字符接着写,直到整个工具调用标记完整闭合。"+
|
||||
"严格禁止:重复任何已输出内容、以 < 或 { 开头、重新生成整段工具调用结构、任何解释或前后缀。"+
|
||||
"参数内容写完后必须立即按原格式依次补齐缺失的闭合标签(parameter、invoke 及外层 calls),然后立刻结束回复;"+
|
||||
"参数内容中的换行、制表符按原样输出即可,无需转义。"+
|
||||
"禁止输出任何解释、Markdown 标记或后记文字。"+
|
||||
"注意合理收尾:内容已经很长,若主体已经完成就立即闭合标签结束,不要无限扩展新段落。"+
|
||||
"你的回复的第一个字符必须正好是被截断处的下一个字符。",
|
||||
deadline, utf8.RuneCountInString(partial), tail)
|
||||
}
|
||||
|
||||
// ToolCallTruncated reports whether text looks like a forced tool-call JSON
|
||||
// reply (see toolsPrompt) that the channel cut off before it could close.
|
||||
// Fenced ```json blocks are unwrapped first, mirroring TryParseToolCalls.
|
||||
// Text without a JSON payload falls back to the DSML dialect check.
|
||||
func ToolCallTruncated(text string) bool {
|
||||
cand := strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(cand, "{") {
|
||||
@@ -68,7 +98,8 @@ func ToolCallTruncated(text string) bool {
|
||||
// evaluate the payload itself, mirroring TryParseToolCalls.
|
||||
i := strings.Index(cand, `{"tool_calls"`)
|
||||
if i <= 0 {
|
||||
return false
|
||||
// 没有 JSON 载荷:再看是不是被截断的 DSML 方言标记。
|
||||
return dsmlTruncated(text)
|
||||
}
|
||||
cand = cand[i:]
|
||||
}
|
||||
@@ -274,7 +305,12 @@ func RunContinued(
|
||||
break
|
||||
}
|
||||
raw := seg.String()
|
||||
piece := TrimOverlap(partial, EscapeControlsAfter(partial, raw))
|
||||
// DSML/XML 内容里的换行是合法字符,不能套用 JSON 字符串的转义规则。
|
||||
healed := raw
|
||||
if !preferDSMLContinue(partial) {
|
||||
healed = EscapeControlsAfter(partial, raw)
|
||||
}
|
||||
piece := TrimOverlap(partial, healed)
|
||||
if piece == "" {
|
||||
warn("tool-call continuation produced no new text", "round", round+1, "seg_chars", len(raw))
|
||||
break
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeepSeek 模型偶尔会退回自己的 DSML 标记方言输出工具调用,形如:
|
||||
//
|
||||
// <||DSML|| calls>
|
||||
// <||DSML|| invoke name="write">
|
||||
// <||DSML|| parameter name="filePath" string="true">a.html</||DSML|| parameter>
|
||||
// </||DSML|| invoke>
|
||||
// </||DSML|| calls>
|
||||
//
|
||||
// 分隔竖线可能全角(U+FF5C)也可能半角('|')、个数不固定(还有
|
||||
// <|DSML|> calls> 这种写法),且截断时闭标签整段缺失。解析策略是先把
|
||||
// DSML 标签改写成普通 XML 标签,再复用既有的 tagged 路径。
|
||||
//
|
||||
// 组1=开/闭前缀(< 或 </),组2=标签名;标记之后的属性与 '>' 不消费,
|
||||
// 保证 invoke name="…" 的属性原样保留。
|
||||
var dsmlTagRe = regexp.MustCompile(`(?i)(</?)\s*[||]{1,4}DSML[||]{1,4}>?\s*([a-z_]\w*)`)
|
||||
|
||||
// normalizeDSML 把 DSML 方言标签改写成普通 XML 标签:竖线标记整体去掉,
|
||||
// 标签名统一小写,calls 归并为已知起点标签 tool_calls。返回 (文本, 是否
|
||||
// 确有标签被改写);不含 DSML 标记时原样返回,避免每轮解析都跑正则。
|
||||
func normalizeDSML(text string) (string, bool) {
|
||||
if !strings.Contains(strings.ToLower(text), "dsml") {
|
||||
return text, false
|
||||
}
|
||||
out := dsmlTagRe.ReplaceAllStringFunc(text, func(m string) string {
|
||||
sub := dsmlTagRe.FindStringSubmatch(m)
|
||||
if sub == nil {
|
||||
return m
|
||||
}
|
||||
name := strings.ToLower(sub[2])
|
||||
if name == "calls" {
|
||||
name = "tool_calls"
|
||||
}
|
||||
return sub[1] + name
|
||||
})
|
||||
return out, out != text
|
||||
}
|
||||
|
||||
// ensureTaggedStart 给归一化后缺少已知起点标签的裸 invoke/function 块补一个
|
||||
// <tool_calls> 起点:findStartTag 只认内置/自定义起点标签,DSML 若没写
|
||||
// calls 外层,不补起点就进不了 tagged 解析路径。
|
||||
func ensureTaggedStart(text string, opts ParseOptions) string {
|
||||
lower := strings.ToLower(text)
|
||||
for _, tag := range []string{"<invoke", "<function"} {
|
||||
i := strings.Index(lower, tag)
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := findStartTag(text[:i], opts); ok {
|
||||
return text // 起点已在该块之前
|
||||
}
|
||||
return text[:i] + "<tool_calls>" + text[i:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// dsmlTruncated 报告 DSML 方言的工具调用块是否被通道输出上限截断:开标签
|
||||
// 多于闭标签即视为未闭合。只认 DSML 方言本身,避免普通散文里的 XML 示例
|
||||
// 误触发续写轮次。
|
||||
func dsmlTruncated(text string) bool {
|
||||
norm, ok := normalizeDSML(text)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// 代码围栏里的标记是文档示例,解析层会跳过,同样不触发续写轮次。
|
||||
start := -1
|
||||
for _, tag := range []string{"<tool_calls>", "<invoke"} {
|
||||
if i := strings.Index(norm, tag); i >= 0 && (start < 0 || i < start) {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
if start >= 0 && isInsideCodeFence(norm, start) {
|
||||
return false
|
||||
}
|
||||
if strings.Count(norm, "<invoke") > strings.Count(norm, "</invoke>") {
|
||||
return true
|
||||
}
|
||||
return strings.Count(norm, "<tool_calls>") > strings.Count(norm, "</tool_calls>")
|
||||
}
|
||||
|
||||
// preferDSMLContinue 报告续写片段应按 DSML/XML 方言组织而非 JSON:文本里
|
||||
// 有 DSML 标记且没有 JSON 工具载荷(有载荷时截断的是 JSON,仍走 JSON 指令)。
|
||||
func preferDSMLContinue(partial string) bool {
|
||||
if strings.Contains(partial, `{"tool_calls"`) {
|
||||
return false
|
||||
}
|
||||
_, ok := normalizeDSML(partial)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dsmlBlock 是用户原始样本的完整形态:双全角竖线标记、calls/invoke/
|
||||
// parameter 三层全部闭合。
|
||||
func dsmlBlock() string {
|
||||
return `<||DSML|| calls><||DSML|| invoke name="write">` +
|
||||
`<||DSML|| parameter name="filePath" string="true">tihu.html</||DSML|| parameter>` +
|
||||
`<||DSML|| parameter name="content" string="true"><html><body>hi</body></html></||DSML|| parameter>` +
|
||||
`</||DSML|| invoke></||DSML|| calls>`
|
||||
}
|
||||
|
||||
func TestParseDSMLInvokeCalls(t *testing.T) {
|
||||
remaining, calls, ok := TryParseToolCallsForTools(dsmlBlock(), toolsFor("write"))
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if remaining != "" {
|
||||
t.Fatalf("remaining = %q, want empty", remaining)
|
||||
}
|
||||
if calls[0].Function.Name != "write" {
|
||||
t.Fatalf("name = %q", calls[0].Function.Name)
|
||||
}
|
||||
if got := argsField(t, calls[0], "filePath"); got != "tihu.html" {
|
||||
t.Fatalf("filePath = %q", got)
|
||||
}
|
||||
if got := argsField(t, calls[0], "content"); got != "<html><body>hi</body></html>" {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户原始样本:截断场景 —— 最后一个 parameter 断在半截,没有任何闭标签。
|
||||
func TestParseDSMLTruncatedInvoke(t *testing.T) {
|
||||
raw := `<||DSML|| calls><||DSML|| invoke name="write">` +
|
||||
`<||DSML|| parameter name="filePath" string="true">tihu.html</||DSML|| parameter>` +
|
||||
`<||DSML|| parameter name="content" string="true"><html><body><canvas id="scene">stopped mid`
|
||||
_, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write"))
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if calls[0].Function.Name != "write" {
|
||||
t.Fatalf("name = %q", calls[0].Function.Name)
|
||||
}
|
||||
if got := argsField(t, calls[0], "filePath"); got != "tihu.html" {
|
||||
t.Fatalf("filePath = %q", got)
|
||||
}
|
||||
want := `<html><body><canvas id="scene">stopped mid`
|
||||
if got := argsField(t, calls[0], "content"); got != want {
|
||||
t.Fatalf("content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 半角单竖线写法(<|DSML|> calls>)同样要能解析。
|
||||
func TestParseDSMLPipeVariant(t *testing.T) {
|
||||
raw := `<|DSML|> calls><|DSML|> invoke name="get_weather">` +
|
||||
`<|DSML|> parameter name="city">北京</|DSML|> parameter>` +
|
||||
`</|DSML|> invoke></|DSML|> calls>`
|
||||
_, calls, ok := TryParseToolCalls(raw)
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if calls[0].Function.Name != "get_weather" || argsField(t, calls[0], "city") != "北京" {
|
||||
t.Fatalf("call = %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 没写 calls 外层的裸 invoke 块:归一化后补 <tool_calls> 起点进 tagged 路径。
|
||||
func TestParseDSMLStandaloneInvoke(t *testing.T) {
|
||||
raw := `<||DSML|| invoke name="read">` +
|
||||
`<||DSML|| parameter name="path">/a.txt</||DSML|| parameter>` +
|
||||
`</||DSML|| invoke>`
|
||||
_, calls, ok := TryParseToolCalls(raw)
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if calls[0].Function.Name != "read" || argsField(t, calls[0], "path") != "/a.txt" {
|
||||
t.Fatalf("call = %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用块两侧的散文要原样保留在 remaining 里。
|
||||
func TestParseDSMLKeepsProse(t *testing.T) {
|
||||
raw := "好的,这就写。\n" + dsmlBlock() + "\n写完了。"
|
||||
remaining, calls, ok := TryParseToolCalls(raw)
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if !strings.Contains(remaining, "好的") || !strings.Contains(remaining, "写完了") {
|
||||
t.Fatalf("remaining = %q", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// 参数内容里形如 [{"name": …}] 的片段是被写入的文件数据,不能被 JSON
|
||||
// 候选路径抢成假调用。
|
||||
func TestParseDSMLContentJSONNotHijacked(t *testing.T) {
|
||||
raw := `<||DSML|| calls><||DSML|| invoke name="write">` +
|
||||
`<||DSML|| parameter name="content" string="true">[{"name": "inner"}]</||DSML|| parameter>` +
|
||||
`</||DSML|| invoke></||DSML|| calls>`
|
||||
_, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write"))
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
if calls[0].Function.Name != "write" {
|
||||
t.Fatalf("content 里的 JSON 抢走了调用名: %+v", calls[0])
|
||||
}
|
||||
if !strings.Contains(calls[0].Function.Arguments, "inner") {
|
||||
t.Fatalf("arguments = %q", calls[0].Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用名不在已提供的工具清单里时,DSML 块按普通文本返回,不产出假调用。
|
||||
func TestParseDSMLRejectsUnknownTool(t *testing.T) {
|
||||
raw := `<||DSML|| invoke name="not_a_tool">` +
|
||||
`<||DSML|| parameter name="x">1</||DSML|| parameter></||DSML|| invoke>`
|
||||
if _, calls, ok := TryParseToolCallsForTools(raw, toolsFor("write")); ok {
|
||||
t.Fatalf("未知工具名被当成了真实调用: %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 代码围栏里的 DSML 示例是文档,不是调用。
|
||||
func TestParseDSMLInsideCodeFenceSkipped(t *testing.T) {
|
||||
raw := "示例:\n```text\n" + dsmlBlock() + "\n```"
|
||||
if _, calls, ok := TryParseToolCalls(raw); ok {
|
||||
t.Fatalf("围栏示例被当成了调用: %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDSML(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`<||DSML|| calls>`, `<tool_calls>`},
|
||||
{`</||DSML|| calls>`, `</tool_calls>`},
|
||||
{`<||DSML|| invoke name="write">`, `<invoke name="write">`},
|
||||
{"</" + "||DSML|| parameter>", "</" + "parameter>"},
|
||||
{`<|DSML|> invoke name="x">`, `<invoke name="x">`},
|
||||
{"</" + "|DSML| parameter>", "</" + "parameter>"},
|
||||
{`<|DSML| parameter name="a">`, `<parameter name="a">`},
|
||||
{"没有 DSML 标记的普通文本", "没有 DSML 标记的普通文本"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, changed := normalizeDSML(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeDSML(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if wantChanged := c.in != c.want; changed != wantChanged {
|
||||
t.Errorf("normalizeDSML(%q) changed = %v, want %v", c.in, changed, wantChanged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallTruncatedDSML(t *testing.T) {
|
||||
open := `<||DSML|| calls><||DSML|| invoke name="write">` +
|
||||
`<||DSML|| parameter name="content">abc`
|
||||
closed := open + `</||DSML|| parameter></||DSML|| invoke></||DSML|| calls>`
|
||||
fenced := "```text\n" + open + "\n```"
|
||||
prose := "不要用 DSML 标记输出工具调用。"
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{open, true},
|
||||
{closed, false},
|
||||
{fenced, false},
|
||||
{prose, false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ToolCallTruncated(c.in); got != c.want {
|
||||
t.Errorf("ToolCallTruncated(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueInstructionDSML(t *testing.T) {
|
||||
partial := strings.Repeat("x", 120) + `<||DSML|| invoke name="write">` + "TAIL80CHARS"
|
||||
got := ContinueInstruction(partial, 2, 5)
|
||||
for _, want := range []string{"2/5", "TAIL80CHARS", "闭合标签", "parameter"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("DSML 续写指令缺少 %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// JSON 分支的收尾 8 字符规则不能混进来。
|
||||
if strings.Contains(got, "右花括号") {
|
||||
t.Errorf("DSML 续写指令混入了 JSON 收尾规则:\n%s", got)
|
||||
}
|
||||
// 含 JSON 载荷时仍走 JSON 指令。
|
||||
jsonPartial := strings.Repeat("x", 120) + `{"tool_calls": [{"trunc`
|
||||
if j := ContinueInstruction(jsonPartial, 2, 5); !strings.Contains(j, "右花括号") {
|
||||
t.Errorf("JSON 部分未走 JSON 指令:\n%s", j)
|
||||
}
|
||||
}
|
||||
|
||||
// DSML 截断回复跨轮续写拼接:内容里的换行按 XML 语义原样保留,
|
||||
// 不套用 JSON 字符串的 \n 转义。
|
||||
func TestRunContinuedStitchesDSML(t *testing.T) {
|
||||
turns := []string{
|
||||
`<||DSML|| calls><||DSML|| invoke name="write">` +
|
||||
`<||DSML|| parameter name="content" string="true">alpha "beta`,
|
||||
" gamma\ndelta" +
|
||||
`</||DSML|| parameter></||DSML|| invoke></||DSML|| calls>`,
|
||||
}
|
||||
prompts := 0
|
||||
var got strings.Builder
|
||||
err := RunContinued(context.Background(),
|
||||
"first-prompt",
|
||||
func(string, int) string { return "continue-prompt" },
|
||||
3,
|
||||
func(_ context.Context, prompt string, emit func(string) error) error {
|
||||
prompts++
|
||||
if prompts > len(turns) {
|
||||
t.Fatalf("unexpected extra send #%d", prompts)
|
||||
}
|
||||
return emit(turns[prompts-1])
|
||||
},
|
||||
func(text string) error { got.WriteString(text); return nil },
|
||||
func(string, ...any) {},
|
||||
func(string, ...any) {},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RunContinued: %v", err)
|
||||
}
|
||||
if prompts != 2 {
|
||||
t.Fatalf("prompts = %d, want 2", prompts)
|
||||
}
|
||||
if ToolCallTruncated(got.String()) {
|
||||
t.Fatal("stitched DSML reply still truncated")
|
||||
}
|
||||
_, calls, ok := TryParseToolCalls(got.String())
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("TryParseToolCalls failed on stitched reply: ok=%v calls=%+v", ok, calls)
|
||||
}
|
||||
want := "alpha \"beta gamma\ndelta"
|
||||
if content := argsField(t, calls[0], "content"); content != want {
|
||||
t.Fatalf("content = %q, want %q", content, want)
|
||||
}
|
||||
}
|
||||
@@ -239,8 +239,9 @@ func toolsPrompt(tools []ToolDef, toolChoice json.RawMessage) string {
|
||||
`{"tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "tool_name", "arguments": "{\"param\": \"value\"}"}}]}`,
|
||||
"",
|
||||
// DeepSeek 模型有时会退回自己的 DSML 标记方言(<|DSML|> calls> /
|
||||
// invoke / parameter),网关不解析该方言,整段会被当普通文本透传,
|
||||
// 客户端收不到 tool_calls——指令层显式禁止,只认上面的 JSON。
|
||||
// invoke / parameter)。解析层已兼容该方言作为兜底(见 dsml.go),
|
||||
// 但指令层仍显式禁止:JSON 是唯一保证完整送达的主路径,DSML 不走
|
||||
// 流式增量、只在收尾整体补发,让模型偏离主路径没有好处。
|
||||
"Never call tools with DSML/XML-style markup (for example `<|DSML|> calls>` or " +
|
||||
"`<|DSML|> invoke name=\"...\">`). Those tags are discarded — only the " +
|
||||
"JSON format above is accepted.",
|
||||
|
||||
@@ -37,8 +37,8 @@ func TestBuildPromptWithTools(t *testing.T) {
|
||||
}
|
||||
|
||||
// 红测试:上游模型曾用 DeepSeek 原生 DSML 标记(<|DSML|> calls> / invoke /
|
||||
// parameter)代替 JSON 输出工具调用,网关三层(流式、解析、续写)都只认
|
||||
// JSON,整段标记被当普通文本发给客户端。指令层必须显式禁止该方言。
|
||||
// parameter)代替 JSON 输出工具调用。解析/续写层已兼容该方言兜底(dsml.go),
|
||||
// 但 JSON 才是保证完整送达的主路径,指令层必须显式禁止该方言。
|
||||
func TestToolsPromptForbidsDSML(t *testing.T) {
|
||||
prompt := BuildPrompt([]ChatMessage{
|
||||
{Role: "user", Content: MessageContent{Text: "Hi"}},
|
||||
|
||||
@@ -45,7 +45,8 @@ func optionsForTools(tools []ToolDef) ParseOptions {
|
||||
// output. It mirrors the Python/Rust implementations: plain JSON, ```json
|
||||
// fences, generic ``` fences, XML/<tool_call>/<invoke> wrappers and the usual
|
||||
// model repairs (unquoted keys, invalid backslashes, field aliases,
|
||||
// swapped/ spilled arguments) are all accepted.
|
||||
// swapped/ spilled arguments) are all accepted, plus DeepSeek's DSML dialect
|
||||
// (see dsml.go) including truncated blocks with missing closing tags.
|
||||
//
|
||||
// Returns the remaining prose plus the parsed tool calls (ok=false when the
|
||||
// text is ordinary chat).
|
||||
@@ -61,12 +62,35 @@ func TryParseToolCallsForTools(text string, tools []ToolDef) (string, []ToolCall
|
||||
|
||||
// TryParseToolCallsWith is TryParseToolCalls with explicit options.
|
||||
func TryParseToolCallsWith(text string, opts ParseOptions) (string, []ToolCall, bool) {
|
||||
norm, isDSML := normalizeDSML(text)
|
||||
// DSML 方言优先按 tagged 路径解析:参数内容里形如 [{"name":…}] 的片段是
|
||||
// 正被写入的文件数据,若先跑 JSON 候选路径会被抢成假调用;解析失败时
|
||||
// 仍回落到下面的常规两段路径。
|
||||
if isDSML {
|
||||
if remaining, calls, ok := parseTaggedToolCalls(ensureTaggedStart(norm, opts), opts); ok && matchesOfferedTools(calls, opts) {
|
||||
return remaining, calls, true
|
||||
}
|
||||
}
|
||||
if remaining, calls, ok := parseJSONToolCalls(text, opts); ok {
|
||||
return remaining, calls, true
|
||||
}
|
||||
return parseTaggedToolCalls(text, opts)
|
||||
}
|
||||
|
||||
// matchesOfferedTools 报告解析结果与工具清单是否相容:DSML 只是兜底方言,
|
||||
// 调用名不在已提供的工具里时多半是散文引用了标记格式,按普通文本返回。
|
||||
func matchesOfferedTools(calls []ToolCall, opts ParseOptions) bool {
|
||||
if len(opts.ToolNames) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range calls {
|
||||
if !containsString(opts.ToolNames, c.Function.Name) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(calls) > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON payload path
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -459,6 +483,14 @@ func parseTaggedToolCalls(text string, opts ParseOptions) (string, []ToolCall, b
|
||||
// or object first, then the XML dialects.
|
||||
func extractTaggedCalls(inner string, opts ParseOptions) []ToolCall {
|
||||
fallbackName := extractXMLName(inner)
|
||||
// 以 <invoke 开头的块是 XML 方言:先按标签解析,否则参数内容里恰好
|
||||
// 形如 [{"name": …}] 的片段(例如正被写入的 JSON 文件)会被 JSON
|
||||
// 路径抢走,产出一个假调用、丢掉真实调用。
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimLeft(inner, " \t\r\n")), "<invoke") {
|
||||
if calls := parseXMLInvokeCalls(inner, opts); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
}
|
||||
if calls := parseJSONInText(inner, opts, fallbackName); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
@@ -511,10 +543,13 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall {
|
||||
head := inner[abs : abs+gt+1]
|
||||
bodyStart := abs + gt + 1
|
||||
ci := strings.Index(lower[bodyStart:], "</invoke>")
|
||||
if ci < 0 {
|
||||
return calls
|
||||
// 截断兜底:闭标签缺失时把剩余文本整体当作 body,最后一个
|
||||
// parameter 的值由 parseXMLParams 取到末尾。
|
||||
bodyEnd := len(inner)
|
||||
if ci >= 0 {
|
||||
bodyEnd = bodyStart + ci
|
||||
}
|
||||
body := inner[abs : bodyStart+ci]
|
||||
body := inner[abs:bodyEnd]
|
||||
name := xmlAttr(head, "name")
|
||||
if name == "" {
|
||||
name = xmlTagValue(body, "name")
|
||||
@@ -522,6 +557,9 @@ func parseXMLInvokeCalls(inner string, opts ParseOptions) []ToolCall {
|
||||
if name != "" {
|
||||
calls = append(calls, makeXMLCall(name, parseXMLParams(body)))
|
||||
}
|
||||
if ci < 0 {
|
||||
return calls
|
||||
}
|
||||
pos = bodyStart + ci + len("</invoke>")
|
||||
}
|
||||
}
|
||||
@@ -592,6 +630,12 @@ func parseXMLParams(body string) map[string]any {
|
||||
break
|
||||
}
|
||||
abs := pos + i
|
||||
// `<param` 是 `<parameter` 的前缀:命中更长标签时跳过,交给
|
||||
// 另一轮处理,否则会把 parameter 的值当成本轮截断尾部覆盖。
|
||||
if abs+len(open) < len(lower) && isTagNameChar(lower[abs+len(open)]) {
|
||||
pos = abs + len(open)
|
||||
continue
|
||||
}
|
||||
gt := strings.IndexByte(body[abs:], '>')
|
||||
if gt < 0 {
|
||||
break
|
||||
@@ -604,13 +648,21 @@ func parseXMLParams(body string) map[string]any {
|
||||
}
|
||||
name := xmlAttr(head, "name")
|
||||
ci := strings.Index(lower[bodyStart:], closeTag)
|
||||
if ci < 0 {
|
||||
break
|
||||
// 截断兜底:闭标签缺失时,值取到下一个同名开标签或 body 末尾。
|
||||
end := len(body)
|
||||
if ci >= 0 {
|
||||
end = bodyStart + ci
|
||||
} else if j := strings.Index(lower[bodyStart:], open); j >= 0 {
|
||||
end = bodyStart + j
|
||||
}
|
||||
if name != "" {
|
||||
params[name] = coerceXMLValue(body[bodyStart : bodyStart+ci])
|
||||
params[name] = coerceXMLValue(body[bodyStart:end])
|
||||
}
|
||||
pos = bodyStart + ci + len(closeTag)
|
||||
if ci < 0 {
|
||||
pos = end
|
||||
continue
|
||||
}
|
||||
pos = end + len(closeTag)
|
||||
}
|
||||
}
|
||||
if len(params) == 0 {
|
||||
@@ -626,6 +678,10 @@ func parseXMLParams(body string) map[string]any {
|
||||
return params
|
||||
}
|
||||
|
||||
func isTagNameChar(c byte) bool {
|
||||
return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
}
|
||||
|
||||
func coerceXMLValue(s string) any {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
|
||||
Reference in new issue
Block a user