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

351 lines
11 KiB
Go

package corpus
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// Conversion 调试日志的 conversion JSONB 字段(协议转换方向元数据)。
type Conversion struct {
ClientFormat string `json:"client_format"`
UpstreamFormat string `json:"upstream_format"`
Chain []string `json:"chain,omitempty"`
Bridge string `json:"bridge,omitempty"`
}
// Record 一条已解码的调试日志(chn_debug_logs 的一行)。
// 只取提取语料所需字段:段1(客户端请求)与段3(上游响应)是转换器的**输入**;
// 段2/段4 是当时的转换**输出**,属于「生产一致性校验」的范畴,本包不处理。
type Record struct {
ID int64
RequestID string
ChannelType int
ModelName string
UpstreamModel string
RelayMode string
InboundPath string
IsStream bool
UpstreamStatus int
CapturedAt time.Time
ClientReqBody []byte // 段1
UpstreamRespBody []byte // 段3
Conversion Conversion
}
// SampleKind 语料类别,决定落盘目录与文件扩展名。
type SampleKind string
const (
// KindRequest 客户端请求语料(段1)→ 驱动请求侧转换的全部方向
KindRequest SampleKind = "request"
// KindStreamResponse 上游流式响应语料(段3,is_stream)→ 驱动流式转换
KindStreamResponse SampleKind = "stream_response"
// KindResponse 上游非流式响应语料(段3)→ 驱动响应侧转换
KindResponse SampleKind = "response"
)
// dirOf 各类语料的落盘子目录。与既有手写语料(inputs / stream_inputs)分开存放:
// 真实语料量大且会持续增长,混进去会让手写的精选集难以阅读,
// 且金样本套件对手写目录有「文件名必须匹配已知场景」的校验。
func (k SampleKind) dirOf() string {
switch k {
case KindRequest:
return "real_inputs"
case KindStreamResponse:
return "real_stream_inputs"
default:
return "real_responses"
}
}
func (k SampleKind) ext() string {
if k == KindStreamResponse {
return ".txt"
}
return ".json"
}
// Sample 一条待落盘的语料。
type Sample struct {
Kind SampleKind
Format string // 目录内的格式前缀:请求取 client_format,响应取 upstream_format
Features []string
ShapeHash string
Body []byte
Meta ManifestEntry
}
// FileName 语料文件名:<格式>__<特征>_<指纹前8位>.<ext>
// 与手写语料的 <格式>__<场景> 约定保持形似,便于人工浏览时归类。
func (s Sample) FileName() string {
feat := "basic"
if len(s.Features) > 0 {
feat = strings.Join(s.Features, "-")
}
short := s.ShapeHash
if len(short) > 8 {
short = short[:8]
}
return fmt.Sprintf("%s__%s_%s%s", s.Format, feat, short, s.Kind.ext())
}
// RelPath 语料相对输出根目录的路径。
func (s Sample) RelPath() string {
return s.Kind.dirOf() + "/" + s.FileName()
}
// ManifestEntry manifest 中的一条溯源记录。
type ManifestEntry struct {
File string `json:"file"`
Kind string `json:"kind"`
Format string `json:"format"`
Features []string `json:"features,omitempty"`
ShapeHash string `json:"shape_hash"`
Occurrences int `json:"occurrences"` // 该形状在采集窗口内出现次数,反映真实流量权重
ClientFormat string `json:"client_format,omitempty"`
UpstreamFormat string `json:"upstream_format,omitempty"`
Chain []string `json:"chain,omitempty"`
Bridge string `json:"bridge,omitempty"`
RelayMode string `json:"relay_mode,omitempty"`
IsStream bool `json:"is_stream"`
ChannelType int `json:"channel_type,omitempty"`
ModelName string `json:"model_name,omitempty"`
UpstreamModel string `json:"upstream_model,omitempty"`
SourceRequestID string `json:"source_request_id,omitempty"`
SourceLogID int64 `json:"source_log_id,omitempty"`
CapturedAt string `json:"captured_at,omitempty"`
}
// Manifest 语料清单,随语料一同落盘,用于溯源与统计。
type Manifest struct {
GeneratedAt string `json:"generated_at"`
Source ManifestSource `json:"source"`
Stats ManifestStats `json:"stats"`
Entries []ManifestEntry `json:"entries"`
Directions map[string]int `json:"directions,omitempty"` // "client→upstream" → 样本数
Formats map[string]int `json:"formats,omitempty"` // 格式 → 样本数
Notes map[string]string `json:"notes,omitempty"`
}
// ManifestSource 采集条件。
type ManifestSource struct {
Since string `json:"since,omitempty"`
Until string `json:"until,omitempty"`
Channel string `json:"channel,omitempty"`
Format string `json:"format,omitempty"`
Limit int `json:"limit"`
}
// ManifestStats 提取统计。
type ManifestStats struct {
Scanned int `json:"scanned"` // 扫描的日志行数
SkippedNoConv int `json:"skipped_no_conv"` // 缺 conversion 元数据被跳过
SkippedEmpty int `json:"skipped_empty"` // 报文体为空
Shapes int `json:"shapes"` // 去重后的不同形状数
Written int `json:"written"` // 实际落盘文件数
}
// ---------- 特征识别 ----------
// featureMarkers 结构特征 → 触发它的 key 或判别式值。
//
// 采用跨格式的通用扫描(遍历 JSON 的全部 key 与判别式字符串),而非按格式写四套解析:
// 各协议表达同一能力的字段名不同但都在这张表里,新增格式无需改代码。
// 特征只用于文件命名与浏览归类,识别偏差不影响语料本身。
var featureMarkers = []struct {
feature string
markers []string
}{
{"tools", []string{"tools", "functionDeclarations", "tool_choice", "toolConfig"}},
{"toolloop", []string{"tool_result", "function_call_output", "functionResponse", "tool_calls", "tool_use", "functionCall", "function_call"}},
{"image", []string{"image_url", "inlineData", "input_image", "inline_data"}},
{"thinking", []string{"thinking", "thinkingConfig", "reasoning", "reasoning_effort", "reasoning_content", "thought", "thoughtSignature"}},
{"websearch", []string{"web_search_options", "googleSearch", "googleSearchRetrieval", "web_search", "web_search_20250305", "groundingMetadata"}},
{"audio", []string{"input_audio", "audio"}},
{"file", []string{"input_file", "fileData", "file_data"}},
}
// DetectFeatures 扫描 JSON 体识别结构特征。多轮对话(消息数 > 2)另加 multiturn。
func DetectFeatures(body []byte) []string {
var v any
if err := json.Unmarshal(body, &v); err != nil {
return nil
}
return detectFeaturesFromValue(v)
}
// DetectStreamFeatures 扫描流式语料:逐帧解析 data 负载后合并特征。
func DetectStreamFeatures(body []byte) []string {
found := map[string]bool{}
for _, f := range parseStreamFrames(body) {
var v any
if err := json.Unmarshal([]byte(f.data), &v); err != nil {
continue
}
for _, feat := range detectFeaturesFromValue(v) {
found[feat] = true
}
}
return sortedFeatures(found)
}
func detectFeaturesFromValue(v any) []string {
seen := map[string]bool{}
collectMarkers(v, seen)
found := map[string]bool{}
for _, fm := range featureMarkers {
for _, m := range fm.markers {
if seen[m] {
found[fm.feature] = true
break
}
}
}
if isMultiTurn(v) {
found["multiturn"] = true
}
return sortedFeatures(found)
}
// collectMarkers 递归收集全部对象 key 与判别式字符串值(type/role 等的取值本身就是标记,
// 如 content 块的 "tool_result"、Responses input 项的 "function_call_output")。
func collectMarkers(v any, seen map[string]bool) {
switch t := v.(type) {
case map[string]any:
for k, val := range t {
seen[k] = true
collectMarkers(val, seen)
}
case []any:
for _, el := range t {
collectMarkers(el, seen)
}
case string:
if len(t) <= enumLikeMax && !strings.ContainsAny(t, " \t\n\r") {
seen[t] = true
}
}
}
// isMultiTurn 顶层消息数组长度 > 2 即视为多轮(各协议的消息数组字段名不同,逐一尝试)。
func isMultiTurn(v any) bool {
obj, ok := v.(map[string]any)
if !ok {
return false
}
for _, key := range []string{"messages", "contents", "input"} {
if arr, ok := obj[key].([]any); ok && len(arr) > 2 {
return true
}
}
return false
}
func sortedFeatures(found map[string]bool) []string {
out := make([]string, 0, len(found))
for f := range found {
out = append(out, f)
}
sort.Strings(out)
return out
}
// ---------- Record → Sample ----------
// Build 从一条调试日志提取语料样本(请求侧 + 响应侧,各自可能为空)。
// 不做去重(由调用方按 ShapeHash 聚合)。
//
// scrub 非 nil 时对报文体做脱敏,且**请求与响应共用同一个 Scrubber**——
// 二者的标识符映射必须一致,否则记录内的引用关系会在两个文件之间对不上。
//
// 结构指纹在**脱敏之后**计算:脱敏保持结构不变,但会重排 key 顺序、
// 归一化数值格式,在原文上算指纹会让同形状的记录因序列化差异被判成不同形状。
func Build(r Record, scrub *Scrubber) []Sample {
var samples []Sample
if scrub == nil {
scrub = NewScrubber(ScrubOptions{})
}
clientBody := scrub.ScrubJSON(r.ClientReqBody)
respBody := r.UpstreamRespBody
if r.IsStream {
respBody = scrub.ScrubStream(respBody)
} else {
respBody = scrub.ScrubJSON(respBody)
}
if len(clientBody) > 0 && r.Conversion.ClientFormat != "" {
if hash, ok := Fingerprint(clientBody); ok {
samples = append(samples, Sample{
Kind: KindRequest,
Format: r.Conversion.ClientFormat,
Features: DetectFeatures(clientBody),
ShapeHash: hash,
Body: clientBody,
Meta: baseMeta(r),
})
}
}
if len(respBody) > 0 && r.Conversion.UpstreamFormat != "" && r.UpstreamStatus == 200 {
if r.IsStream {
samples = append(samples, Sample{
Kind: KindStreamResponse,
Format: r.Conversion.UpstreamFormat,
Features: DetectStreamFeatures(respBody),
ShapeHash: FingerprintStream(respBody),
Body: respBody,
Meta: baseMeta(r),
})
} else if hash, ok := Fingerprint(respBody); ok {
samples = append(samples, Sample{
Kind: KindResponse,
Format: r.Conversion.UpstreamFormat,
Features: DetectFeatures(respBody),
ShapeHash: hash,
Body: respBody,
Meta: baseMeta(r),
})
}
}
// 回填各样本自身的 meta 字段
for i := range samples {
s := &samples[i]
s.Meta.File = s.RelPath()
s.Meta.Kind = string(s.Kind)
s.Meta.Format = s.Format
s.Meta.Features = s.Features
s.Meta.ShapeHash = s.ShapeHash
}
return samples
}
func baseMeta(r Record) ManifestEntry {
captured := ""
if !r.CapturedAt.IsZero() {
captured = r.CapturedAt.Format(time.RFC3339)
}
return ManifestEntry{
ClientFormat: r.Conversion.ClientFormat,
UpstreamFormat: r.Conversion.UpstreamFormat,
Chain: r.Conversion.Chain,
Bridge: r.Conversion.Bridge,
RelayMode: r.RelayMode,
IsStream: r.IsStream,
ChannelType: r.ChannelType,
ModelName: r.ModelName,
UpstreamModel: r.UpstreamModel,
SourceRequestID: r.RequestID,
SourceLogID: r.ID,
CapturedAt: captured,
}
}