* 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):补充手写测试数据集
284 lines
9.6 KiB
Go
284 lines
9.6 KiB
Go
// Package corpus 从渠道调试日志提取协议转换测试语料。
|
||
//
|
||
// 背景:协议转换的金样本/能力守恒/模糊测试此前全部由手写语料驱动,而手写语料的
|
||
// 固有盲区正是「写的人想不到的形状」——真实客户端(Claude Code、Cursor、各家 SDK)
|
||
// 发出的请求远比人工构造的刁钻。chn_debug_logs 已经完整记录了四段报文,
|
||
// 本包负责把它转成金样本套件能直接消费的语料文件。
|
||
//
|
||
// 关键取舍:真实流量冗余极大(万级记录往往只有几十种结构),直接落盘既臃肿又无益。
|
||
// 因此以**结构指纹**去重,每种形状只留代表样本。指纹只用于去重判等,
|
||
// 其激进程度不影响落盘内容——语料始终是原文。
|
||
package corpus
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// freeTextKeys 这些 key 的字符串值是自由文本,对转换行为无影响,
|
||
// 计算指纹时一律折叠为类型占位——否则每条不同的用户输入都会算作一种新形状,
|
||
// 去重就完全失效了。
|
||
//
|
||
// 注意 type / role / finish_reason 等判别式字段**不在**此列:它们决定转换分支,
|
||
// 是结构的一部分。
|
||
var freeTextKeys = map[string]bool{
|
||
"model": true, "text": true, "data": true, "url": true, "image_url": true,
|
||
"arguments": true, "partial_json": true, "content": true, "query": true,
|
||
"instructions": true, "system": true, "prompt": true, "uri": true,
|
||
"title": true, "output": true, "description": true, "name": true,
|
||
"id": true, "call_id": true, "tool_call_id": true, "tool_use_id": true,
|
||
"item_id": true, "response_id": true, "responseId": true,
|
||
"signature": true, "thought_signature": true, "thoughtSignature": true,
|
||
"encrypted_content": true, "page_age": true, "thinking": true,
|
||
"reasoning_content": true, "user": true, "metadata": true,
|
||
// 会话级标识符(codex 把同一会话 UUID 塞进 prompt_cache_key / session_id /
|
||
// thread_id / turn_id / root_turn_id / x-codex-* 镜像等大量字段):值随会话变化、
|
||
// 与结构无关——不折叠的话跨会话的同形状请求会被判成不同形状,去重碎片化。
|
||
// key 名无法穷举,裸 UUID 形态的值另由 uuidPattern 按值形态兜底
|
||
"prompt_cache_key": true, "session_id": true, "thread_id": true,
|
||
"turn_id": true, "root_turn_id": true,
|
||
"x-codex-window-id": true, "x-codex-installation-id": true,
|
||
}
|
||
|
||
// enumLikeMax 判定为枚举值的字符串长度上限(超过视为自由文本)。
|
||
const enumLikeMax = 40
|
||
|
||
// uuidPattern 标准 UUID 形态(允许尾部 :N 轮次后缀,codex 的 x-codex-window-id 用)。
|
||
// 会话/安装级标识符经常被客户端塞进任意命名的字段(prompt_cache_key、x-codex-* 镜像等),
|
||
// key 名不可枚举,只能按值形态识别——UUID 永远不是决定转换分支的判别式。
|
||
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(:[0-9]+)?$`)
|
||
|
||
// bareHexIDPattern OpenAI 风格的 32 位十六进制 id(无连字符)。
|
||
var bareHexIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||
|
||
// uuidContainsPattern 非锚定的 UUID 形态(用于对象 key 检测)。
|
||
var uuidContainsPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
|
||
|
||
// isIDLikeKey 判断对象 key 是否为「按 id 索引」的动态 key。
|
||
// Responses 的 usage 明细按 id 建 map(实测见过 msg_<uuid>、at_<uuid>、
|
||
// 32 位十六进制等形态),id 由服务端逐次生成——原样进指纹的话这类报文
|
||
// 每次都是新形状,去重永远失效。前缀不可枚举(msg_/at_/rs_/未来更多),
|
||
// 按「key 中包含完整 UUID」识别;正常字段名不会包含 UUID,不会误伤。
|
||
func isIDLikeKey(k string) bool {
|
||
return uuidContainsPattern.MatchString(k) || bareHexIDPattern.MatchString(k)
|
||
}
|
||
|
||
// Fingerprint 计算 JSON 体的结构指纹。非 JSON 返回 ok=false(流式语料走 FingerprintStream)。
|
||
func Fingerprint(body []byte) (string, bool) {
|
||
var v any
|
||
if err := json.Unmarshal(body, &v); err != nil {
|
||
return "", false
|
||
}
|
||
return hashShape(shapeOf(v, "")), true
|
||
}
|
||
|
||
// FingerprintStream 计算 SSE / NDJSON 流的结构指纹。
|
||
//
|
||
// 逐帧取 data 负载的形状,再对帧形状序列做游程压缩——一个流有 3 个文本增量帧
|
||
// 还是 300 个,结构上是同一件事,不折叠的话每条流都是独一无二的形状、去重失效。
|
||
func FingerprintStream(body []byte) string {
|
||
frames := parseStreamFrames(body)
|
||
shapes := make([]any, 0, len(frames))
|
||
for _, f := range frames {
|
||
var v any
|
||
if err := json.Unmarshal([]byte(f.data), &v); err != nil {
|
||
// 非 JSON 负载(如 [DONE] 哨兵)按原文入形状,它是协议的一部分
|
||
shapes = append(shapes, map[string]any{"event": f.event, "raw": f.data})
|
||
continue
|
||
}
|
||
shapes = append(shapes, map[string]any{"event": f.event, "data": shapeOf(v, "")})
|
||
}
|
||
return hashShape(runLengthCollapse(shapes))
|
||
}
|
||
|
||
func hashShape(shape any) string {
|
||
raw, err := json.Marshal(shape)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(raw)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// shapeOf 递归归一化为「结构」:
|
||
// - 对象:保留 key(排序后),值递归;
|
||
// - 数组:逐元素求形状后游程压缩,计数分桶(1/2/3/many);
|
||
// - 标量:判别式字符串保留原值,其余折叠为类型占位。
|
||
//
|
||
// key 参数是该值所在的字段名,用于判断字符串是否为自由文本。
|
||
func shapeOf(v any, key string) any {
|
||
switch t := v.(type) {
|
||
case map[string]any:
|
||
keys := make([]string, 0, len(t))
|
||
for k := range t {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
out := make(map[string]any, len(t))
|
||
// 按 id 索引的动态 key(usage 明细等)折叠进同一个桶:
|
||
// 保留条目数量(分桶)与值的形状,抹掉 id 本身
|
||
var idKeyed []any
|
||
for _, k := range keys {
|
||
if isIDLikeKey(k) {
|
||
idKeyed = append(idKeyed, shapeOf(t[k], k))
|
||
continue
|
||
}
|
||
out[k] = shapeOf(t[k], k)
|
||
}
|
||
if idKeyed != nil {
|
||
out["#id-keyed"] = runLengthCollapse(idKeyed)
|
||
}
|
||
return out
|
||
|
||
case []any:
|
||
if len(t) == 0 {
|
||
return []any{}
|
||
}
|
||
shapes := make([]any, 0, len(t))
|
||
for _, el := range t {
|
||
shapes = append(shapes, shapeOf(el, key))
|
||
}
|
||
return runLengthCollapse(shapes)
|
||
|
||
case string:
|
||
if isEnumLike(key, t) {
|
||
return t
|
||
}
|
||
return "#str"
|
||
case float64:
|
||
return "#num"
|
||
case bool:
|
||
return "#bool"
|
||
case nil:
|
||
return "#null"
|
||
default:
|
||
return "#unknown"
|
||
}
|
||
}
|
||
|
||
// isEnumLike 判断字符串值是否为判别式枚举(决定转换分支,须进指纹)。
|
||
func isEnumLike(key, val string) bool {
|
||
if freeTextKeys[key] {
|
||
return false
|
||
}
|
||
// 裸 UUID 是客户端标识符而非枚举:不同会话的 UUID 各不相同,
|
||
// 当枚举保留会让跨会话的同形状请求指纹互异、去重碎片化
|
||
if uuidPattern.MatchString(val) {
|
||
return false
|
||
}
|
||
// 含 CJK 等表意字符的值是用户内容而非枚举:协议判别式全是英文标识符,
|
||
// 而中文文本通常**不含空格**,「无空格即枚举」的启发式对 CJK 失效——
|
||
// 不排除会把短中文(用户输入、思考内容)原样放进语料
|
||
for _, r := range val {
|
||
if r >= 0x2E80 {
|
||
return false
|
||
}
|
||
}
|
||
if val == "" || len(val) > enumLikeMax {
|
||
return false
|
||
}
|
||
// 含空白即视为自然语言
|
||
return !strings.ContainsAny(val, " \t\n\r")
|
||
}
|
||
|
||
// runLengthCollapse 对形状序列做游程压缩:连续的相同形状折叠为 {shape, n},
|
||
// n 按 1/2/3/many 分桶——区分「单条消息」与「多轮对话」这类结构差异,
|
||
// 同时把「50 轮」与「51 轮」这类长度噪音抹平。
|
||
func runLengthCollapse(shapes []any) []any {
|
||
out := make([]any, 0, len(shapes))
|
||
var lastKey string
|
||
count := 0
|
||
|
||
flush := func(shape any) {
|
||
if count == 0 {
|
||
return
|
||
}
|
||
out = append(out, map[string]any{"shape": shape, "n": bucketCount(count)})
|
||
}
|
||
|
||
var lastShape any
|
||
for _, s := range shapes {
|
||
raw, err := json.Marshal(s)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
k := string(raw)
|
||
if count > 0 && k == lastKey {
|
||
count++
|
||
continue
|
||
}
|
||
flush(lastShape)
|
||
lastShape, lastKey, count = s, k, 1
|
||
}
|
||
flush(lastShape)
|
||
return out
|
||
}
|
||
|
||
func bucketCount(n int) string {
|
||
switch {
|
||
case n <= 1:
|
||
return "1"
|
||
case n == 2:
|
||
return "2"
|
||
case n == 3:
|
||
return "3"
|
||
default:
|
||
return "many"
|
||
}
|
||
}
|
||
|
||
// streamFrame 流中的一帧(SSE 事件名 + data 负载;NDJSON 的 event 为空)。
|
||
type streamFrame struct {
|
||
event string
|
||
data string
|
||
}
|
||
|
||
// parseStreamFrames 把 SSE 或 NDJSON 流拆成帧。
|
||
// 含 "data:" 前缀行的按 SSE 解析(空行分帧),否则按 NDJSON 逐行解析。
|
||
func parseStreamFrames(body []byte) []streamFrame {
|
||
text := string(body)
|
||
if !strings.Contains(text, "data:") {
|
||
return parseNDJSONFrames(text)
|
||
}
|
||
|
||
var frames []streamFrame
|
||
for _, block := range strings.Split(text, "\n\n") {
|
||
block = strings.TrimSpace(block)
|
||
if block == "" {
|
||
continue
|
||
}
|
||
var f streamFrame
|
||
var dataLines []string
|
||
for _, line := range strings.Split(block, "\n") {
|
||
switch {
|
||
case strings.HasPrefix(line, "event:"):
|
||
f.event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||
case strings.HasPrefix(line, "data:"):
|
||
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||
}
|
||
}
|
||
if len(dataLines) == 0 && f.event == "" {
|
||
continue
|
||
}
|
||
f.data = strings.Join(dataLines, "\n")
|
||
frames = append(frames, f)
|
||
}
|
||
return frames
|
||
}
|
||
|
||
func parseNDJSONFrames(text string) []streamFrame {
|
||
var frames []streamFrame
|
||
for _, line := range strings.Split(text, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
frames = append(frames, streamFrame{data: line})
|
||
}
|
||
return frames
|
||
}
|