mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 08:55:28 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9a544cc53 | ||
|
|
d848d24a82 | ||
|
|
0a2fc42dad | ||
|
|
e615f1710f | ||
|
|
8f01aa224c | ||
|
|
31e64ff31d | ||
|
|
5984802df4 | ||
|
|
e0ed4ba238 | ||
|
|
ae37654893 | ||
|
|
aa7f821151 | ||
|
|
f7426f9f04 | ||
|
|
787e034174 | ||
|
|
d73f7b8b73 | ||
|
|
b8d844e2f6 |
143
LICENSE
143
LICENSE
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|||||||
10
README.MD
10
README.MD
@@ -82,16 +82,6 @@ flowchart LR
|
|||||||
- **前端**:React 管理台(`webui/`),运行时托管静态构建产物
|
- **前端**:React 管理台(`webui/`),运行时托管静态构建产物
|
||||||
- **部署**:本地运行、Docker、Vercel Serverless、Linux systemd
|
- **部署**:本地运行、Docker、Vercel Serverless、Linux systemd
|
||||||
|
|
||||||
### 3.X 底层架构调整(相较旧版本)
|
|
||||||
|
|
||||||
- **统一路由内核**:所有协议入口统一汇聚到 `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/toolcall`)与 Vercel Node 侧(`internal/js/helpers/stream-tool-sieve`)保持一致的解析/防泄漏语义;当前以 XML/Markup 家族为主(`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml 变体)。
|
|
||||||
- **配置与运行时设置解耦**:静态配置(`config`)与运行时策略(`settings`)通过 Admin API 分离管理,支持热更新和密码轮换失效旧 JWT。
|
|
||||||
- **流式能力升级**:`/v1/responses` 与 `/v1/chat/completions` 共享更一致的工具调用增量输出策略,降低不同 SDK 下的行为差异。
|
|
||||||
- **可观测与可运维增强**:`/healthz`、`/readyz`、`/admin/version`、`/admin/dev/captures` 形成排障闭环,便于发布后验证。
|
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
|
|||||||
10
README.en.md
10
README.en.md
@@ -80,16 +80,6 @@ For the full module-by-module architecture and directory responsibilities, see [
|
|||||||
- **Frontend**: React admin panel (`webui/`), served as static build at runtime
|
- **Frontend**: React admin panel (`webui/`), served as static build at runtime
|
||||||
- **Deployment**: local run, Docker, Vercel serverless, Linux systemd
|
- **Deployment**: local run, Docker, Vercel serverless, Linux systemd
|
||||||
|
|
||||||
### 3.X Architecture Changes (vs older releases)
|
|
||||||
|
|
||||||
- **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/toolcall`) and Vercel Node (`internal/js/helpers/stream-tool-sieve`) share aligned parsing/anti-leak semantics, now centered on XML/Markup-family payloads (`<tool_call>` / `<function_call>` / `<invoke>` / `tool_use` / antml variants).
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Key Capabilities
|
## Key Capabilities
|
||||||
|
|
||||||
| Capability | Details |
|
| Capability | Details |
|
||||||
|
|||||||
31
internal/adapter/openai/citation_links.go
Normal file
31
internal/adapter/openai/citation_links.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var citationMarkerPattern = regexp.MustCompile(`(?i)\[citation:\s*(\d+)\]`)
|
||||||
|
|
||||||
|
func replaceCitationMarkersWithLinks(text string, links map[int]string) string {
|
||||||
|
if strings.TrimSpace(text) == "" || len(links) == 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return citationMarkerPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
sub := citationMarkerPattern.FindStringSubmatch(match)
|
||||||
|
if len(sub) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(strings.TrimSpace(sub[1]))
|
||||||
|
if err != nil || idx <= 0 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(links[idx])
|
||||||
|
if url == "" {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[%d](%s)", idx, url)
|
||||||
|
})
|
||||||
|
}
|
||||||
28
internal/adapter/openai/citation_links_test.go
Normal file
28
internal/adapter/openai/citation_links_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReplaceCitationMarkersWithLinks(t *testing.T) {
|
||||||
|
raw := "这是一条更新[citation:1],更多信息见[citation:2]。"
|
||||||
|
links := map[int]string{
|
||||||
|
1: "https://example.com/news-1",
|
||||||
|
2: "https://example.com/news-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
got := replaceCitationMarkersWithLinks(raw, links)
|
||||||
|
want := "这是一条更新[1](https://example.com/news-1),更多信息见[2](https://example.com/news-2)。"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceCitationMarkersWithLinksKeepsUnknownIndex(t *testing.T) {
|
||||||
|
raw := "只有一个来源[citation:1],未知来源[citation:3]。"
|
||||||
|
links := map[int]string{1: "https://example.com/a"}
|
||||||
|
|
||||||
|
got := replaceCitationMarkersWithLinks(raw, links)
|
||||||
|
want := "只有一个来源[1](https://example.com/a),未知来源[citation:3]。"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
|
h.handleStream(w, r, resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames)
|
h.handleNonStream(w, r.Context(), resp, sessionID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) {
|
func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAuth, sessionID string) {
|
||||||
@@ -124,7 +124,7 @@ func (h *Handler) autoDeleteRemoteSession(ctx context.Context, a *auth.RequestAu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled bool, toolNames []string) {
|
func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, resp *http.Response, completionID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string) {
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
@@ -137,6 +137,9 @@ func (h *Handler) handleNonStream(w http.ResponseWriter, ctx context.Context, re
|
|||||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||||
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
finalThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||||
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
finalText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||||
|
if searchEnabled {
|
||||||
|
finalText = replaceCitationMarkersWithLinks(finalText, result.CitationLinks)
|
||||||
|
}
|
||||||
if writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) {
|
if writeUpstreamEmptyOutputError(w, finalText, result.ContentFilter) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) {
|
|||||||
)
|
)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, nil)
|
h.handleNonStream(rec, context.Background(), resp, "cid-empty", "deepseek-chat", "prompt", false, false, nil)
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected status 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func TestHandleNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWithoutOutp
|
|||||||
)
|
)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
h.handleNonStream(rec, context.Background(), resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, nil)
|
h.handleNonStream(rec, context.Background(), resp, "cid-empty-filtered", "deepseek-chat", "prompt", false, false, nil)
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected status 400 for filtered upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected status 400 for filtered upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ func TestHandleNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testing.T) {
|
|||||||
)
|
)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, nil)
|
h.handleNonStream(rec, context.Background(), resp, "cid-thinking-only", "deepseek-reasoner", "prompt", true, false, nil)
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected status 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,10 +112,10 @@ func (h *Handler) Responses(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleResponsesStream(w, r, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
|
h.handleResponsesStream(w, r, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.ToolNames, stdReq.ToolChoice, traceID)
|
h.handleResponsesNonStream(w, resp, owner, responseID, stdReq.ResponseModel, stdReq.FinalPrompt, stdReq.Thinking, stdReq.Search, stdReq.ToolNames, stdReq.ToolChoice, traceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
|
func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Response, owner, responseID, model, finalPrompt string, thinkingEnabled, searchEnabled bool, toolNames []string, toolChoice util.ToolChoicePolicy, traceID string) {
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
@@ -126,6 +126,9 @@ func (h *Handler) handleResponsesNonStream(w http.ResponseWriter, resp *http.Res
|
|||||||
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
stripReferenceMarkers := h.compatStripReferenceMarkers()
|
||||||
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
sanitizedThinking := cleanVisibleOutput(result.Thinking, stripReferenceMarkers)
|
||||||
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
sanitizedText := cleanVisibleOutput(result.Text, stripReferenceMarkers)
|
||||||
|
if searchEnabled {
|
||||||
|
sanitizedText = replaceCitationMarkersWithLinks(sanitizedText, result.CitationLinks)
|
||||||
|
}
|
||||||
if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) {
|
if writeUpstreamEmptyOutputError(w, sanitizedText, result.ContentFilter) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceViolation(t *testing.T) {
|
|||||||
Allowed: map[string]struct{}{"read_file": {}},
|
Allowed: map[string]struct{}{"read_file": {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, []string{"read_file"}, policy, "")
|
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, []string{"read_file"}, policy, "")
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ func TestHandleResponsesNonStreamRequiredToolChoiceIgnoresThinkingToolPayload(t
|
|||||||
Allowed: map[string]struct{}{"read_file": {}},
|
Allowed: map[string]struct{}{"read_file": {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, []string{"read_file"}, policy, "")
|
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", true, false, []string{"read_file"}, policy, "")
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 422 for required tool_choice violation, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T)
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "")
|
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 429 for empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -267,7 +267,7 @@ func TestHandleResponsesNonStreamReturnsContentFilterErrorWhenUpstreamFilteredWi
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, nil, util.DefaultToolChoicePolicy(), "")
|
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-chat", "prompt", false, false, nil, util.DefaultToolChoicePolicy(), "")
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("expected 400 for filtered empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 400 for filtered empty upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ func TestHandleResponsesNonStreamReturns429WhenUpstreamHasOnlyThinking(t *testin
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
|
|
||||||
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, nil, util.DefaultToolChoicePolicy(), "")
|
h.handleResponsesNonStream(rec, resp, "owner-a", "resp_test", "deepseek-reasoner", "prompt", true, false, nil, util.DefaultToolChoicePolicy(), "")
|
||||||
if rec.Code != http.StatusTooManyRequests {
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 429 for thinking-only upstream output, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
168
internal/sse/citation_links.go
Normal file
168
internal/sse/citation_links.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type citationLinkCollector struct {
|
||||||
|
ordered []string
|
||||||
|
explicitRaw map[int]string
|
||||||
|
hasZeroIdx bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCitationLinkCollector() *citationLinkCollector {
|
||||||
|
return &citationLinkCollector{
|
||||||
|
explicitRaw: map[int]string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) ingestChunk(chunk map[string]any) {
|
||||||
|
if c == nil || len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.walkValue(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) build() map[int]string {
|
||||||
|
out := make(map[int]string, len(c.explicitRaw)+len(c.ordered))
|
||||||
|
for idx, u := range c.buildNormalizedExplicit() {
|
||||||
|
out[idx] = u
|
||||||
|
}
|
||||||
|
for i, u := range c.ordered {
|
||||||
|
idx := i + 1
|
||||||
|
if _, exists := out[idx]; !exists {
|
||||||
|
out[idx] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) buildNormalizedExplicit() map[int]string {
|
||||||
|
out := make(map[int]string, len(c.explicitRaw))
|
||||||
|
|
||||||
|
// Default behavior keeps positive indices as-is (one-based payloads).
|
||||||
|
for idx, u := range c.explicitRaw {
|
||||||
|
if idx <= 0 || strings.TrimSpace(u) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[idx] = u
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.hasZeroIdx {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// If zero index appears, upstream may be using zero-based indices.
|
||||||
|
// Add shifted candidates and resolve conflicts using ordered appearance,
|
||||||
|
// which matches visible citation marker order in response text.
|
||||||
|
for rawIdx, u := range c.explicitRaw {
|
||||||
|
if rawIdx < 0 || strings.TrimSpace(u) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized := rawIdx + 1
|
||||||
|
existing, exists := out[normalized]
|
||||||
|
if !exists {
|
||||||
|
out[normalized] = u
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.preferURLForIndex(normalized, existing, u) == u {
|
||||||
|
out[normalized] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) preferURLForIndex(idx int, current, candidate string) string {
|
||||||
|
if idx <= 0 || idx > len(c.ordered) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
expected := c.ordered[idx-1]
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(expected) == "":
|
||||||
|
return current
|
||||||
|
case candidate == expected && current != expected:
|
||||||
|
return candidate
|
||||||
|
default:
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) walkValue(v any) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range x {
|
||||||
|
c.walkValue(item)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
c.captureURLAndIndex(x)
|
||||||
|
for _, vv := range x {
|
||||||
|
c.walkValue(vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) captureURLAndIndex(m map[string]any) {
|
||||||
|
url := strings.TrimSpace(asString(m["url"]))
|
||||||
|
if !isWebURL(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.addOrdered(url)
|
||||||
|
|
||||||
|
idx, hasIdx := citationIndexFromAny(m["cite_index"])
|
||||||
|
if !hasIdx {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx == 0 {
|
||||||
|
c.hasZeroIdx = true
|
||||||
|
}
|
||||||
|
if existing, ok := c.explicitRaw[idx]; ok && strings.TrimSpace(existing) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.explicitRaw[idx] = url
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *citationLinkCollector) addOrdered(url string) {
|
||||||
|
c.ordered = append(c.ordered, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func citationIndexFromAny(v any) (int, bool) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int:
|
||||||
|
return x, true
|
||||||
|
case int32:
|
||||||
|
return int(x), true
|
||||||
|
case int64:
|
||||||
|
return int(x), true
|
||||||
|
case float32:
|
||||||
|
return int(x), true
|
||||||
|
case float64:
|
||||||
|
return int(x), true
|
||||||
|
case string:
|
||||||
|
s := strings.TrimSpace(x)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWebURL(v string) bool {
|
||||||
|
v = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
return strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type CollectResult struct {
|
|||||||
Text string
|
Text string
|
||||||
Thinking string
|
Thinking string
|
||||||
ContentFilter bool
|
ContentFilter bool
|
||||||
|
CitationLinks map[int]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectStream fully consumes a DeepSeek SSE response and separates
|
// CollectStream fully consumes a DeepSeek SSE response and separates
|
||||||
@@ -28,11 +29,15 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
|
|||||||
text := strings.Builder{}
|
text := strings.Builder{}
|
||||||
thinking := strings.Builder{}
|
thinking := strings.Builder{}
|
||||||
contentFilter := false
|
contentFilter := false
|
||||||
|
collector := newCitationLinkCollector()
|
||||||
currentType := "text"
|
currentType := "text"
|
||||||
if thinkingEnabled {
|
if thinkingEnabled {
|
||||||
currentType = "thinking"
|
currentType = "thinking"
|
||||||
}
|
}
|
||||||
_ = deepseek.ScanSSELines(resp, func(line []byte) bool {
|
_ = deepseek.ScanSSELines(resp, func(line []byte) bool {
|
||||||
|
if chunk, done, parsed := ParseDeepSeekSSELine(line); parsed && !done {
|
||||||
|
collector.ingestChunk(chunk)
|
||||||
|
}
|
||||||
result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType)
|
result := ParseDeepSeekContentLine(line, thinkingEnabled, currentType)
|
||||||
currentType = result.NextType
|
currentType = result.NextType
|
||||||
if !result.Parsed {
|
if !result.Parsed {
|
||||||
@@ -59,5 +64,6 @@ func CollectStream(resp *http.Response, thinkingEnabled bool, closeBody bool) Co
|
|||||||
Text: text.String(),
|
Text: text.String(),
|
||||||
Thinking: thinking.String(),
|
Thinking: thinking.String(),
|
||||||
ContentFilter: contentFilter,
|
ContentFilter: contentFilter,
|
||||||
|
CitationLinks: collector.build(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,76 @@ func TestCollectStreamWithCitation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectStreamExtractsCitationLinks(t *testing.T) {
|
||||||
|
resp := makeHTTPResponse(
|
||||||
|
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":0},{\"url\":\"https://example.com/b\",\"cite_index\":1}]}\n" +
|
||||||
|
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2]\"}\n" +
|
||||||
|
"data: [DONE]\n",
|
||||||
|
)
|
||||||
|
result := CollectStream(resp, false, false)
|
||||||
|
|
||||||
|
if got := result.CitationLinks[1]; got != "https://example.com/a" {
|
||||||
|
t.Fatalf("expected citation 1 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[2]; got != "https://example.com/b" {
|
||||||
|
t.Fatalf("expected citation 2 link, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectStreamExtractsCitationLinksForSequentialZeroBasedIndices(t *testing.T) {
|
||||||
|
resp := makeHTTPResponse(
|
||||||
|
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":0},{\"url\":\"https://example.com/b\",\"cite_index\":1},{\"url\":\"https://example.com/c\",\"cite_index\":2}]}\n" +
|
||||||
|
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2][citation:3]\"}\n" +
|
||||||
|
"data: [DONE]\n",
|
||||||
|
)
|
||||||
|
result := CollectStream(resp, false, false)
|
||||||
|
|
||||||
|
if got := result.CitationLinks[1]; got != "https://example.com/a" {
|
||||||
|
t.Fatalf("expected citation 1 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[2]; got != "https://example.com/b" {
|
||||||
|
t.Fatalf("expected citation 2 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[3]; got != "https://example.com/c" {
|
||||||
|
t.Fatalf("expected citation 3 link, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectStreamExtractsCitationLinksForOneBasedIndices(t *testing.T) {
|
||||||
|
resp := makeHTTPResponse(
|
||||||
|
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":1},{\"url\":\"https://example.com/b\",\"cite_index\":2}]}\n" +
|
||||||
|
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2]\"}\n" +
|
||||||
|
"data: [DONE]\n",
|
||||||
|
)
|
||||||
|
result := CollectStream(resp, false, false)
|
||||||
|
|
||||||
|
if got := result.CitationLinks[1]; got != "https://example.com/a" {
|
||||||
|
t.Fatalf("expected citation 1 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[2]; got != "https://example.com/b" {
|
||||||
|
t.Fatalf("expected citation 2 link, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectStreamExtractsCitationLinksWithRepeatedURLsAndNilIndices(t *testing.T) {
|
||||||
|
resp := makeHTTPResponse(
|
||||||
|
"data: {\"p\":\"response/fragments/-1/results\",\"v\":[{\"url\":\"https://example.com/a\",\"cite_index\":null},{\"url\":\"https://example.com/a\",\"cite_index\":null},{\"url\":\"https://example.com/b\",\"cite_index\":null}]}\n" +
|
||||||
|
"data: {\"p\":\"response/content\",\"v\":\"结论[citation:1][citation:2][citation:3]\"}\n" +
|
||||||
|
"data: [DONE]\n",
|
||||||
|
)
|
||||||
|
result := CollectStream(resp, false, false)
|
||||||
|
|
||||||
|
if got := result.CitationLinks[1]; got != "https://example.com/a" {
|
||||||
|
t.Fatalf("expected citation 1 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[2]; got != "https://example.com/a" {
|
||||||
|
t.Fatalf("expected citation 2 link, got %q", got)
|
||||||
|
}
|
||||||
|
if got := result.CitationLinks[3]; got != "https://example.com/b" {
|
||||||
|
t.Fatalf("expected citation 3 link, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectStreamMultipleThinkingChunks(t *testing.T) {
|
func TestCollectStreamMultipleThinkingChunks(t *testing.T) {
|
||||||
resp := makeHTTPResponse(
|
resp := makeHTTPResponse(
|
||||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"part1\"}\n" +
|
"data: {\"p\":\"response/thinking_content\",\"v\":\"part1\"}\n" +
|
||||||
|
|||||||
Reference in New Issue
Block a user