From 0cbc2c875dd300615dd825a44a57a1ca570daae8 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Wed, 29 Apr 2026 15:43:09 +0700 Subject: [PATCH] fix(openai): keep citation indexes one-based --- docs/prompt-compatibility.md | 2 ++ .../httpapi/openai/citation_links_test.go | 28 +++++++++++++++++++ .../httpapi/openai/shared/citation_links.go | 17 +++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 16cf38c..6c136cb 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -105,6 +105,8 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` - 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;thinking / reasoning 增量仍按原样先发,只有在结束收尾时才可能补发最终工具调用结果。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning,思维链只用于检测,不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。 - OpenAI Chat / Responses 的空回复错误处理之前会默认做一次内部补偿重试:第一次上游完整结束后,如果最终可见正文为空、没有解析到工具调用、也没有已经向客户端流式发出工具调用,并且终止原因不是 `content_filter`,兼容层会复用同一个 `chat_session_id`、账号、token 与工具策略,把原始 completion `prompt` 追加固定后缀 `Previous reply had no visible output. Please regenerate the visible final answer or tool call now.` 后重新提交一次。重试遵循 DeepSeek 多轮对话协议:从第一次上游 SSE 流中提取 `response_message_id`,并在重试 payload 中设置 `parent_message_id` 为该值,使重试成为同一会话的后续轮次而非断裂的根消息;同时重新获取一次 PoW(若 PoW 获取失败则回退到原始 PoW)。该重试不会重新标准化消息、不会新建 session、不会切换账号,也不会向流式客户端插入重试标记;第二次 thinking / reasoning 会按正常增量直接接到第一次之后,并继续使用 overlap trim 去重。若第二次仍为空,终端错误码仍保持现有 `upstream_empty_output`;若任一尝试触发空 `content_filter`,不做补偿重试并保持 `content_filter` 错误。JS Vercel 运行时同样设置 `parent_message_id`,但因无法直接调用 PoW API 而复用原始 PoW。 +- OpenAI Chat / Responses 在最终可见正文渲染阶段,会把 DeepSeek 搜索返回中的 `[citation:N]` / `[reference:N]` 标记替换成对应 Markdown 链接。`citation` 标记按一基序号解析;`reference` 标记只有在同一段正文中出现 `[reference:0]`(允许冒号后有空格)时才按零基序号映射,并且不会影响同段正文里的 `citation` 标记。 + ## 5. prompt 是怎么拼出来的 OpenAI Chat / Responses 在标准化后、current input file 之前,会默认执行 `thinking_injection` 增强。它参考 DeepSeek V4 “把控制指令放在 user 消息末尾更稳定”的用法,在最新 user message 后追加思考增强提示词。当前内置默认提示词以 `Reasoning Effort: Absolute maximum with no shortcuts permitted.` 开头,并继续要求模型充分分解问题、覆盖潜在路径与边界条件、把完整推演过程显式写出。该开关默认启用,可通过 `thinking_injection.enabled=false` 关闭;也可以通过 `thinking_injection.prompt` 自定义提示词,留空时使用内置默认提示词。 diff --git a/internal/httpapi/openai/citation_links_test.go b/internal/httpapi/openai/citation_links_test.go index a7f10d0..3c891ab 100644 --- a/internal/httpapi/openai/citation_links_test.go +++ b/internal/httpapi/openai/citation_links_test.go @@ -54,3 +54,31 @@ func TestReplaceCitationMarkersWithLinksSupportsReferenceZeroBased(t *testing.T) t.Fatalf("expected %q, got %q", want, got) } } + +func TestReplaceCitationMarkersWithLinksKeepsCitationOneBasedWithZeroBasedReference(t *testing.T) { + raw := "引用[citation:1],来源[reference:0],后续[reference:1]。" + links := map[int]string{ + 1: "https://example.com/first", + 2: "https://example.com/second", + } + + got := replaceCitationMarkersWithLinks(raw, links) + want := "引用[1](https://example.com/first),来源[0](https://example.com/first),后续[1](https://example.com/second)。" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestReplaceCitationMarkersWithLinksDetectsSpacedReferenceZeroBased(t *testing.T) { + raw := "来源[reference: 0] 与 [reference: 1]。" + links := map[int]string{ + 1: "https://example.com/first", + 2: "https://example.com/second", + } + + got := replaceCitationMarkersWithLinks(raw, links) + want := "来源[0](https://example.com/first) 与 [1](https://example.com/second)。" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/internal/httpapi/openai/shared/citation_links.go b/internal/httpapi/openai/shared/citation_links.go index 9b2b77f..b4e2f33 100644 --- a/internal/httpapi/openai/shared/citation_links.go +++ b/internal/httpapi/openai/shared/citation_links.go @@ -13,7 +13,7 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string { if strings.TrimSpace(text) == "" || len(links) == 0 { return text } - zeroBased := strings.Contains(strings.ToLower(text), "[reference:0]") + zeroBasedReference := hasZeroBasedReferenceMarker(text) return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string { sub := citationMarkerPattern.FindStringSubmatch(match) if len(sub) < 3 { @@ -24,7 +24,7 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string { return match } lookupIdx := idx - if zeroBased { + if strings.EqualFold(sub[1], "reference") && zeroBasedReference { lookupIdx = idx + 1 } url := strings.TrimSpace(links[lookupIdx]) @@ -34,3 +34,16 @@ func ReplaceCitationMarkersWithLinks(text string, links map[int]string) string { return fmt.Sprintf("[%d](%s)", idx, url) }) } + +func hasZeroBasedReferenceMarker(text string) bool { + for _, sub := range citationMarkerPattern.FindAllStringSubmatch(text, -1) { + if len(sub) < 3 || !strings.EqualFold(sub[1], "reference") { + continue + } + idx, err := strconv.Atoi(strings.TrimSpace(sub[2])) + if err == nil && idx == 0 { + return true + } + } + return false +}