* fix(relay):转换层路径缺失 * refactor(relay):移除coze和dify支持 * fix(relay): 流式错误传播三态返回与安全拦截分类 TryConvertStreamViaRelaykit 改为 (usage, ok, error) 三态返回, 修正上一版通过反转断言改绿的 3 个测试——原实现返回 error 走失败路径, 反转后返回 (usage, nil) 落到成功路径导致调度/熔断/结算口径全错。 - stream.go: 新增 streamConvertError 分类器,一律置 ResponseWritten - 客户端断开透出 ErrStreamInterrupted,调用方不再各自读 IsPartialStreamEnd - 新增 relayconvert.ErrContentBlocked 哨兵,6 处 Gemini 出站安全拦截 wrap - response.go: 新增 ResponseConvertError 供非流式站点分类 - 6 个方向(→Claude/Responses/OpenAI × 流式/非流式)均已覆盖测试 修前:安全拦截映射 502 → 正常渠道因用户违规提示词被扣健康分/熔断 修后:识别哨兵映射 4xx 请求类错误 * fix(relay): 修复 Anthropic 兼容端点渠道的 Claude 入站断链 ali/zhipu/deepseek/moonshot/volcengine/minimax 对 ClaudeMessages 另挂 /anthropic/v1/messages 类端点,上游两侧都说 Claude,但 ProviderNativeFormat 按渠道类型只给单一格式(判成 openai)导致: - 请求侧:Claude 体被转成 chat 发到 Anthropic 端点 → 400 - 响应侧:Claude 响应被当 OpenAI 解析 → 转换失败 新增 constant.HasNativeClaudeEndpoint 谓词,在 EffectiveUpstreamFormat 与 inboundMatchesChannelNative 两处叠加判定, Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式不转换。 登记判据:GetRequestURL 对 ClaudeMessages 返回与 chat 不同的地址。 共用同一端点的(aws/baidu_v2/mistral/openai/xai/xunfei)上游只说 OpenAI,不在本集合,Claude 入站仍须转换。 * fix(relay): relaykit 路径接回 5 家私有请求后处理 relaykit 矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest 整个不再被调用。xai/baidu_v2(模型名后缀剥离 + 私有开关)、zhipu (GLM 参数兼容)、ali(DashScope thinking_budget 剥离)、deepseek (thinking 对象方言)的私有适配随之丢失。 最实际的是模型名:未配映射时 UpstreamModel = row.ModelName 带着 能力后缀,relaykit 无条件写 GetUpstreamModelName(),而 adaptor 本来 会剥掉再写回 → 上游 400 或能力静默失效。 新增可选能力接口 RequestPostProcessor,handler.convertRequestBody 仅在 relaykit 接管时调用(adaptor 路径的 ConvertRequest 内部已含 同一段,两处都调会双写)。5 家各把尾段抽成 postProcessRequest 与钩子 共用。新增接口断言与功能测试。 * docs(relaykit): 更新工作状态与回归记录 - §5.1/§5.2 已完成,新增 §3.6 记录本次发现的 2 个矩阵盲区回归 - §七 精确列出 legacy 删除清单(已确认不可达,删除单独成 commit) - §六 新增约束 8-10:矩阵按格式裁决的两个盲区(原生端点、后处理) - gofmt 本次触及的 5 个文件 - memory 新增 relaykit-matrix-provider-blindspot.md 根因记录 * refactor(relay): 删除被 relaykit 取代的 legacy 协议转换代码 净删约 6400 行。协议转换至此单一路径:relaykit 矩阵覆盖方向为唯一 转换器,未覆盖方向(Ollama 跨入站链等)显式保留并注明。 - 删 claude/gemini converter.go 整文件(o2c/o2g 请求侧双实现) - 删 openai/converter.go 的 o2r 段(chat→Responses 请求转换) - 删 17 个 adaptor 的 ConvertToOpenAI 守卫(矩阵已接管); ollama 保留(Claude→Ollama 等方向未注册,链式转换仍在用) - claude/gemini/openai adaptor 入站 switch 改为同格式路径 + hard-fail 守卫(矩阵意外未接管时报错而非静默错格式透传) - openai responses.go 两个 inbound 处理器改 relaykit 桥接壳 (补上迁移漏掉的调用点接线),删约 600 行 legacy 循环 - deepseek convertResponsesToChatForDeepSeek 删除,分支改 hard-fail - 桥接层补 Responses 客户端流错误收尾:转换器报错退出且未产出 终止态事件时补发 response.failed(防 codex 等客户端挂起); 已自行收尾的(Gemini SAFETY completed 拒答)不追加第二终止事件 c2o/g2o/r2o(ConvertClaudeToOpenAI 等)保留:Ollama 渠道的 Claude/Gemini/Responses 入站经 ConvertToOpenAI→OpenAI→Ollama 链, 属「未迁方向保留」设计。 测试:新增删除项的替代断言(hard-fail 守卫、PostProcess 钩子、 response.failed 收尾);删测已删函数的用例。全仓 build/vet 通过, relaykit 全绿,relay 侧仅剩 helper 既有失败。 * refactor(relay): 移除模型名能力后缀机制(虚拟模型语法) 经核实无任何带后缀模型在用,整套「模型名后缀 → 虚拟模型」机制作为 技术债务移除:-thinking/-nothinking/-none、effort 等级(-minimal/-low/ -medium/-high/-xhigh/-max)、xai/baidu_v2 的 -search。思考/搜索能力 此后一律由客户端请求参数表达(thinking 对象、reasoning_effort、 reasoning.effort),网关只做协议转换不做能力注入。 宿主侧: - 删 helper/thinking.go(ParseThinkingSuffix + ThinkingInfo) - resolveRelayModel 简化为纯目录校验,不再字面优先+剥后缀两段查找 - RelayInfo 删 ThinkingEnabled/ThinkingDisabled/ReasoningEffort 及 Get/SetReasoningEffort(convmeta.Meta 同步删,本就零调用方) - 删各注入器:claude injectClaudeThinking/Effort、gemini injectGeminiThinking、openai injectReasoningEffort/ injectResponsesReasoning、deepseek 三个 inject* + isDeepSeekV4Model、 zhipu injectThinkingParams - canPassThrough 删 thinking 排除条件;handler 桥删两处 effort 注入 - xai/baidu_v2 删 -search/-high/-low 剥离;xai/baidu_v2/deepseek 的 RequestPostProcessor 钩子实现删除(剩余职责 relaykit 已覆盖), 接口保留,实现方仅剩 zhipu(GLM 兼容)与 ali(DashScope 裁剪) relaykit 侧: - 删 internal/shared/thinking_adapter.go 与 reasoning/ 包 - convmeta.Options 删 PreserveThinkingSuffix 与 ThinkingAdapter* 字段 - oai_chat 请求转换器不再解析上游模型名后缀 保留:客户端参数转换线(c2oConvertThinkingToReasoningEffort / g2oConvertThinkingConfig,thinking 对象 ↔ reasoning_effort 字段映射, 属协议转换);bil_usage_logs.reasoning_effort 列(此后恒空); BaseModelName 字段(responses 路由记录在用)。 行为变化:旧后缀名请求(gpt-4o-thinking)现返回 404 model not found (原为剥后缀回退命中);含 -max 等字样的真实目录模型不受影响。 全仓 build/vet 通过,relaykit 全绿,relay 侧仅剩 helper 既有失败。 * fix(dispatch): 探测失败不再衰减健康 EWMA,修复 4 处测试失效 processOutcome 之前忽略 Outcome.Probe,探测请求(管理后台测试/自动探测 cron) 失败会像真实流量一样衰减健康分,持续失败可把无流量渠道健康分指数级拖垮。 现探测失败跳过健康 EWMA 写入(探测成功仍照常记录),熔断窗口计数不受影响。 顺带修复 3 处与实现漂移的过期测试: - admin 角色迁移文件路径引用了已重命名的迁移脚本 - billing 账单摘要在无逐项 token 明细时漏了小计×倍率行(同时同步了乘数标签改名) - relay/helper 错误提示文案测试仍留着改中文前的英文期望值 * refactor(relay):补充gemini和claude协议的转换 * fix(relay):修复gemini转换问题 * fix(relay):优化工具转换 * feat(test):添加测试用例 * feat(test):添加测试用例 * feat(relay):补充转换矩阵 * feat(relay):补充协议转换测试 * fix(relay):修复vertex缺陷 * feat(relay):补充协议间的工具转换 * feat(relay):添加语料采集功能 * fix(relay):修复claude输出协议转换问题 * fix(relay):修复codex请求claude模型无法使用搜索工具问题 * fix(relay):修复codex请求gemini模型无法使用搜索工具问题 * refactor(relay):修改真实语料处理逻辑,将真实响应数据移出仓库 * fix(relay):补充手写测试数据集
303 lines
10 KiB
Go
303 lines
10 KiB
Go
package shared
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/qianfree/team-api/relaykit/dto"
|
||
)
|
||
|
||
// 服务端联网搜索的**响应侧**证据(引用来源)跨协议中间表示。
|
||
//
|
||
// 请求侧的能力映射见 websearch.go:客户端要求联网 → 各格式的原生搜索构件。
|
||
// 但上游真的去搜了之后,「搜到了什么」同样需要还原回客户端协议,否则客户端只拿到
|
||
// 一段被搜索增强的纯文本——**看不到来源、无法核查、也无法展示引用**,而这正是
|
||
// 联网搜索的主要价值。各格式的表达方式:
|
||
//
|
||
// Gemini candidate.groundingMetadata:groundingChunks(来源)+
|
||
// groundingSupports(文本片段→来源索引)+ webSearchQueries(实际查询)
|
||
// Claude content 块对:server_tool_use(发起搜索)+ web_search_tool_result(结果列表)
|
||
// 另有 usage.server_tool_use.web_search_requests 供按次计费
|
||
// OpenAI message.annotations:[{type:"url_citation", url_citation:{url,title,start_index,end_index}}]
|
||
// Responses output_text.annotations:[{type:"url_citation", url, title, start_index, end_index}]
|
||
//
|
||
// 本文件只做协议还原;按次计费的定价挂接属宿主计费层,此处仅把
|
||
// web_search_requests 计数透出到 usage,使计费层有据可依。
|
||
|
||
// GroundingSource 一条被引用的网页来源。
|
||
type GroundingSource struct {
|
||
URL string
|
||
Title string
|
||
}
|
||
|
||
// GroundingSupport 回答文本的一个片段与其来源的对应关系。
|
||
// StartIndex / EndIndex 是**字节**偏移(与 Gemini 一致,Go 字符串切片同为字节口径)。
|
||
type GroundingSupport struct {
|
||
StartIndex int
|
||
EndIndex int
|
||
Text string // 片段原文,用于校验偏移是否仍然对齐
|
||
SourceIndices []int // 指向 GroundingCitations.Sources
|
||
}
|
||
|
||
// GroundingCitations 一次带搜索的回答所附带的全部引用证据。
|
||
type GroundingCitations struct {
|
||
Queries []string
|
||
Sources []GroundingSource
|
||
Supports []GroundingSupport
|
||
}
|
||
|
||
// IsEmpty 无来源即视为没有可还原的引用证据(只有 queries 而无来源时,
|
||
// 客户端拿不到任何可核查的东西,不值得合成空的工具结果块)。
|
||
func (c *GroundingCitations) IsEmpty() bool {
|
||
return c == nil || len(c.Sources) == 0
|
||
}
|
||
|
||
// SearchRequestCount 本次回答实际发起的搜索次数,供计费层按次计价。
|
||
// Gemini 以 webSearchQueries 列出实际查询;缺失但有来源时按 1 次计。
|
||
func (c *GroundingCitations) SearchRequestCount() int {
|
||
if c.IsEmpty() {
|
||
return 0
|
||
}
|
||
if n := len(c.Queries); n > 0 {
|
||
return n
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// queryText 把多个查询合并为单个 query 字符串。
|
||
// Claude 的 server_tool_use.input 是单个 query,而 Gemini 把多次查询的结果**汇集**在
|
||
// 一组 groundingChunks 里、无法回溯每条来源属于哪次查询,故合并表达而非拆成多对
|
||
// (拆开会导致来源在各对之间重复,虚增引用数)。真实次数由 SearchRequestCount 承载。
|
||
func (c *GroundingCitations) queryText() string {
|
||
if len(c.Queries) == 0 {
|
||
return ""
|
||
}
|
||
return strings.Join(c.Queries, ", ")
|
||
}
|
||
|
||
// ---------- 解析:Gemini groundingMetadata ----------
|
||
|
||
// geminiGroundingMetadata 是 groundingMetadata 的解析形态。
|
||
// DTO 侧保持 `any` 以保证原生透传方向的 wire 保真(未知字段不丢),
|
||
// 这里按需重解析出所需字段。
|
||
type geminiGroundingMetadata struct {
|
||
WebSearchQueries []string `json:"webSearchQueries"`
|
||
GroundingChunks []struct {
|
||
Web *struct {
|
||
URI string `json:"uri"`
|
||
Title string `json:"title"`
|
||
} `json:"web"`
|
||
} `json:"groundingChunks"`
|
||
GroundingSupports []struct {
|
||
Segment *struct {
|
||
StartIndex int `json:"startIndex"`
|
||
EndIndex int `json:"endIndex"`
|
||
Text string `json:"text"`
|
||
} `json:"segment"`
|
||
GroundingChunkIndices []int `json:"groundingChunkIndices"`
|
||
} `json:"groundingSupports"`
|
||
}
|
||
|
||
// ParseGeminiGrounding 从 candidate.groundingMetadata 提取引用证据。
|
||
// 无 grounding、解析失败、或无任何网页来源时返回 nil(调用方据此不做还原)。
|
||
func ParseGeminiGrounding(meta any) *GroundingCitations {
|
||
if meta == nil {
|
||
return nil
|
||
}
|
||
raw, err := json.Marshal(meta)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var parsed geminiGroundingMetadata
|
||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
return nil
|
||
}
|
||
|
||
citations := &GroundingCitations{Queries: parsed.WebSearchQueries}
|
||
// chunk 下标 → Sources 下标的重映射:非 web 类型的 chunk(如检索库片段)
|
||
// 不产生来源,会让两侧下标错位,必须显式重映射而非直接沿用原下标
|
||
chunkToSource := make(map[int]int, len(parsed.GroundingChunks))
|
||
for i, chunk := range parsed.GroundingChunks {
|
||
if chunk.Web == nil || chunk.Web.URI == "" {
|
||
continue
|
||
}
|
||
chunkToSource[i] = len(citations.Sources)
|
||
citations.Sources = append(citations.Sources, GroundingSource{
|
||
URL: chunk.Web.URI,
|
||
Title: chunk.Web.Title,
|
||
})
|
||
}
|
||
if len(citations.Sources) == 0 {
|
||
return nil
|
||
}
|
||
|
||
for _, support := range parsed.GroundingSupports {
|
||
if support.Segment == nil {
|
||
continue
|
||
}
|
||
indices := make([]int, 0, len(support.GroundingChunkIndices))
|
||
for _, ci := range support.GroundingChunkIndices {
|
||
if si, ok := chunkToSource[ci]; ok {
|
||
indices = append(indices, si)
|
||
}
|
||
}
|
||
if len(indices) == 0 {
|
||
continue
|
||
}
|
||
citations.Supports = append(citations.Supports, GroundingSupport{
|
||
StartIndex: support.Segment.StartIndex,
|
||
EndIndex: support.Segment.EndIndex,
|
||
Text: support.Segment.Text,
|
||
SourceIndices: indices,
|
||
})
|
||
}
|
||
return citations
|
||
}
|
||
|
||
// AlignSupports 校正片段偏移,使其对齐**转换后**的回答文本。
|
||
//
|
||
// 为什么必须校正:Gemini 的偏移是相对上游原始候选文本的,而转换后的正文可能与之不同
|
||
// (思考 part 被剥离、多个 text part 被拼接、前缀被裁剪)。偏移错位会让客户端把引用
|
||
// 高亮在错误的位置上——比没有引用更糟。
|
||
//
|
||
// 策略:segment.text 给出了片段原文,据此校验;对不上就在正文里重新定位;
|
||
// 仍找不到则丢弃该片段的偏移(保留来源本身,只是不带位置)。
|
||
func (c *GroundingCitations) AlignSupports(text string) {
|
||
if c == nil || len(c.Supports) == 0 {
|
||
return
|
||
}
|
||
aligned := make([]GroundingSupport, 0, len(c.Supports))
|
||
for _, s := range c.Supports {
|
||
if s.Text == "" {
|
||
continue // 无原文可校验,不敢保留偏移
|
||
}
|
||
// 原偏移仍然对齐:直接沿用
|
||
if s.StartIndex >= 0 && s.EndIndex <= len(text) && s.StartIndex < s.EndIndex &&
|
||
text[s.StartIndex:s.EndIndex] == s.Text {
|
||
aligned = append(aligned, s)
|
||
continue
|
||
}
|
||
// 否则在正文中重新定位
|
||
if idx := strings.Index(text, s.Text); idx >= 0 {
|
||
s.StartIndex = idx
|
||
s.EndIndex = idx + len(s.Text)
|
||
aligned = append(aligned, s)
|
||
}
|
||
// 找不到:该片段不带偏移地丢弃,来源本身仍在 Sources 里
|
||
}
|
||
c.Supports = aligned
|
||
}
|
||
|
||
// ---------- 产出:Claude ----------
|
||
|
||
// ToClaudeSearchBlocks 产出 Claude 的 server_tool_use + web_search_tool_result 块对。
|
||
// Gemini 不提供 Anthropic 的 encrypted_content / page_age,仅还原 url + title——
|
||
// 能力(来源可见、可核查)保住,细粒度字段静默降级。
|
||
func (c *GroundingCitations) ToClaudeSearchBlocks(idSeed string) []dto.ClaudeContentBlock {
|
||
if c.IsEmpty() {
|
||
return nil
|
||
}
|
||
toolUseID := fmt.Sprintf("srvtoolu_%s", idSeed)
|
||
|
||
results := make([]map[string]any, 0, len(c.Sources))
|
||
for _, src := range c.Sources {
|
||
results = append(results, map[string]any{
|
||
"type": "web_search_result",
|
||
"url": src.URL,
|
||
"title": src.Title,
|
||
})
|
||
}
|
||
|
||
return []dto.ClaudeContentBlock{
|
||
{
|
||
Type: "server_tool_use",
|
||
ID: toolUseID,
|
||
Name: "web_search",
|
||
Input: map[string]any{"query": c.queryText()},
|
||
},
|
||
{
|
||
Type: "web_search_tool_result",
|
||
ToolUseID: toolUseID,
|
||
Content: results,
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---------- 产出:OpenAI chat / Responses 的 url_citation ----------
|
||
|
||
// ToOpenAIAnnotations 产出 chat 的 annotations(url_citation 形态)。
|
||
// 有对齐后的片段时按「片段 × 来源」逐条产出并带偏移,否则每个来源产出一条无偏移的引用。
|
||
func (c *GroundingCitations) ToOpenAIAnnotations() []any {
|
||
if c.IsEmpty() {
|
||
return nil
|
||
}
|
||
out := make([]any, 0, len(c.Sources))
|
||
emit := func(src GroundingSource, start, end int, withRange bool) {
|
||
citation := map[string]any{"url": src.URL, "title": src.Title}
|
||
if withRange {
|
||
citation["start_index"] = start
|
||
citation["end_index"] = end
|
||
}
|
||
out = append(out, map[string]any{"type": "url_citation", "url_citation": citation})
|
||
}
|
||
|
||
if len(c.Supports) > 0 {
|
||
for _, s := range c.Supports {
|
||
for _, si := range s.SourceIndices {
|
||
if si < len(c.Sources) {
|
||
emit(c.Sources[si], s.StartIndex, s.EndIndex, true)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
for _, src := range c.Sources {
|
||
emit(src, 0, 0, false)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ToResponsesAnnotations 产出 Responses 的 output_text.annotations(url_citation 形态)。
|
||
func (c *GroundingCitations) ToResponsesAnnotations() []dto.ResponsesAnnotation {
|
||
if c.IsEmpty() {
|
||
return nil
|
||
}
|
||
out := make([]dto.ResponsesAnnotation, 0, len(c.Sources))
|
||
if len(c.Supports) > 0 {
|
||
for _, s := range c.Supports {
|
||
for _, si := range s.SourceIndices {
|
||
if si >= len(c.Sources) {
|
||
continue
|
||
}
|
||
out = append(out, dto.ResponsesAnnotation{
|
||
Type: "url_citation",
|
||
URL: c.Sources[si].URL,
|
||
Title: c.Sources[si].Title,
|
||
StartIndex: s.StartIndex,
|
||
EndIndex: s.EndIndex,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
for _, src := range c.Sources {
|
||
out = append(out, dto.ResponsesAnnotation{Type: "url_citation", URL: src.URL, Title: src.Title})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ToResponsesWebSearchCallItem 产出 Responses 的 web_search_call 输出项,
|
||
// 让客户端看到「模型发起过搜索」这一事实(与 annotations 互补:前者是动作,后者是引用)。
|
||
func (c *GroundingCitations) ToResponsesWebSearchCallItem(idSeed string) map[string]any {
|
||
if c.IsEmpty() {
|
||
return nil
|
||
}
|
||
return map[string]any{
|
||
"type": "web_search_call",
|
||
"id": fmt.Sprintf("ws_%s", idSeed),
|
||
"status": "completed",
|
||
"action": map[string]any{"type": "search", "query": c.queryText()},
|
||
}
|
||
}
|