Files
Qian 1f2046d0fd 重构协议转换,实现更完整的转换功能 (#26)
* 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):补充手写测试数据集
2026-09-12 16:08:05 +08:00

185 lines
6.0 KiB
Go

package shared
import (
"encoding/json"
"testing"
"github.com/qianfree/team-api/relaykit/dto"
)
// 服务端联网搜索的跨协议中间表示:探测与产出两侧都要覆盖,
// 任一侧失准都会让能力在转换中静默丢失(客户端要求联网、却以没搜索的方式成功返回)。
func TestDetectWebSearchFromOpenAI(t *testing.T) {
tests := []struct {
name string
opts string
wantNil bool
wantSize string
}{
{"空对象即启用", `{}`, false, ""},
{"带上下文规模", `{"search_context_size":"high"}`, false, "high"},
{"带位置", `{"user_location":{"type":"approximate","approximate":{"city":"SH"}}}`, false, ""},
{"字段缺失", ``, true, ""},
{"显式 null", `null`, true, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DetectWebSearchFromOpenAI(json.RawMessage(tt.opts))
if tt.wantNil {
if got != nil {
t.Fatalf("期望未探测到搜索,got %+v", got)
}
return
}
if got == nil {
t.Fatal("应探测到搜索能力")
}
if got.SearchContextSize != tt.wantSize {
t.Errorf("SearchContextSize = %q, want %q", got.SearchContextSize, tt.wantSize)
}
})
}
}
func TestDetectWebSearchFromClaudeTools(t *testing.T) {
maxUses := 5
tools := []dto.ClaudeTool{
{Name: "get_weather", InputSchema: map[string]any{}},
{Type: "web_search_20250305", Name: "web_search", MaxUses: &maxUses, AllowedDomains: []string{"example.com"}},
}
got := DetectWebSearchFromClaudeTools(tools)
if got == nil {
t.Fatal("应在混合工具列表中探测到 web_search")
}
if got.MaxUses == nil || *got.MaxUses != 5 {
t.Errorf("MaxUses 丢失: %v", got.MaxUses)
}
if len(got.AllowedDomains) != 1 || got.AllowedDomains[0] != "example.com" {
t.Errorf("AllowedDomains 丢失: %v", got.AllowedDomains)
}
// 仅自定义函数时不得误报
if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Name: "f"}}) != nil {
t.Error("纯自定义函数不应被识别为搜索")
}
// 其他内置工具不得误报
if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Type: "code_execution_20250522", Name: "code"}}) != nil {
t.Error("code_execution 不应被识别为搜索")
}
}
func TestDetectWebSearchFromGeminiTools(t *testing.T) {
cases := []struct {
name string
tools string
want bool
}{
{"googleSearch", `[{"googleSearch":{}}]`, true},
{"旧版 googleSearchRetrieval", `[{"googleSearchRetrieval":{}}]`, true},
{"与函数声明共存", `[{"functionDeclarations":[{"name":"f"}]},{"googleSearch":{}}]`, true},
{"仅函数声明", `[{"functionDeclarations":[{"name":"f"}]}]`, false},
{"空", ``, false},
{"畸形 JSON", `{not json`, false},
// 对象形态(非数组)不是合法 Gemini tools,不应误判
{"对象形态", `{"googleSearch":{}}`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := DetectWebSearchFromGeminiTools(json.RawMessage(tc.tools)) != nil
if got != tc.want {
t.Errorf("探测结果 = %v, want %v", got, tc.want)
}
})
}
}
func TestDetectWebSearchFromResponsesTools(t *testing.T) {
cases := []struct {
name string
tools string
want bool
}{
{"web_search", `[{"type":"web_search"}]`, true},
{"web_search_preview 变体", `[{"type":"web_search_preview"}]`, true},
{"与函数共存", `[{"type":"function","name":"f"},{"type":"web_search"}]`, true},
{"仅函数", `[{"type":"function","name":"f"}]`, false},
{"空", ``, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := DetectWebSearchFromResponsesTools(json.RawMessage(tc.tools)) != nil
if got != tc.want {
t.Errorf("探测结果 = %v, want %v", got, tc.want)
}
})
}
spec := DetectWebSearchFromResponsesTools(json.RawMessage(`[{"type":"web_search","search_context_size":"low"}]`))
if spec == nil || spec.SearchContextSize != "low" {
t.Errorf("search_context_size 未提取: %+v", spec)
}
}
// TestWebSearchSpec_RoundTripAcrossFormats 跨格式往返:任一格式产出的构件
// 再被对应的探测器识别,能力都不得丢失——这是两跳链(经 chat 中枢)能保住搜索的前提。
func TestWebSearchSpec_RoundTripAcrossFormats(t *testing.T) {
maxUses := 3
origin := &WebSearchSpec{MaxUses: &maxUses, SearchContextSize: "high"}
// → chat → 回探测
chatOpts := origin.ToOpenAIOptions()
back := DetectWebSearchFromOpenAI(chatOpts)
if back == nil {
t.Fatal("chat 往返后能力丢失")
}
if back.SearchContextSize != "high" {
t.Errorf("chat 往返丢失 search_context_size: %+v", back)
}
// → Claude → 回探测
claudeTool := origin.ToClaudeTool()
back = DetectWebSearchFromClaudeTools([]dto.ClaudeTool{claudeTool})
if back == nil {
t.Fatal("Claude 往返后能力丢失")
}
if back.MaxUses == nil || *back.MaxUses != 3 {
t.Errorf("Claude 往返丢失 max_uses: %+v", back.MaxUses)
}
// → Responses → 回探测
respToolRaw, err := json.Marshal([]any{origin.ToResponsesTool()})
if err != nil {
t.Fatalf("marshal 失败: %v", err)
}
if DetectWebSearchFromResponsesTools(respToolRaw) == nil {
t.Fatal("Responses 往返后能力丢失")
}
// → Gemini → 回探测(Gemini 侧无可承载的约束字段,只保能力本身)
geminiRaw, err := json.Marshal([]any{GeminiGoogleSearchEntry()})
if err != nil {
t.Fatalf("marshal 失败: %v", err)
}
if DetectWebSearchFromGeminiTools(geminiRaw) == nil {
t.Fatal("Gemini 往返后能力丢失")
}
}
// TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled 无约束时必须产出空对象而非 null:
// OpenAI 的约定是「字段存在即启用」,产出 null 等于没启用。
func TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled(t *testing.T) {
opts := (&WebSearchSpec{}).ToOpenAIOptions()
if string(opts) != `{}` {
t.Errorf("空 spec 应产出 {}, got %s", opts)
}
if DetectWebSearchFromOpenAI(opts) == nil {
t.Error("产出的 options 应能被自身探测器识别为启用")
}
// nil 接收者产出 nil(调用方据此判断不注入字段)
var nilSpec *WebSearchSpec
if nilSpec.ToOpenAIOptions() != nil {
t.Error("nil spec 应产出 nil")
}
}