* 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):补充手写测试数据集
440 lines
15 KiB
Go
440 lines
15 KiB
Go
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/os/gcmd"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
|
||
"github.com/qianfree/team-api/internal/dao"
|
||
"github.com/qianfree/team-api/internal/utility/corpus"
|
||
)
|
||
|
||
// corpus-extract:从 chn_debug_logs 提取协议转换测试语料。
|
||
//
|
||
// 为什么做成 CLI 而不是管理端接口:调试日志是四段完整报文(含请求体原文),
|
||
// 不该经 HTTP 出网;CLI 走应用自身配置连库,查询、去重、落盘一次完成。
|
||
//
|
||
// 默认对报文体做脱敏:自由文本等长替换、内联数据换占位图,同时**保持引用一致性**
|
||
// (tool_call_id / 工具名的映射在一条记录内前后一致)与判别式字段原值。
|
||
// --no-scrub 可关闭,但那样落盘的是报文原文,仅限确知无敏感数据的测试流量。
|
||
|
||
const (
|
||
// defaultCorpusOutput 语料输出根目录:**仓库外的独立目录**(语料体积由结构决定、
|
||
// 动辄数百 MB,不进 team-api 仓库)。可用环境变量 TEAM_API_CORPUS_DIR 覆盖,
|
||
// 下游测试按同一变量发现语料(缺失即跳过)。
|
||
defaultCorpusOutput = "../team-api-corpus"
|
||
defaultScanLimit = 5000
|
||
defaultMaxInline = 8192
|
||
defaultMaxText = 16384
|
||
)
|
||
|
||
var corpusExtractCmd = gcmd.Command{
|
||
Name: "corpus-extract",
|
||
Usage: "corpus-extract [-o <目录>] [--since <时间>] [--channel <ID,...>] [--limit N]",
|
||
Brief: "从渠道调试日志提取协议转换测试语料(默认脱敏)",
|
||
Arguments: []gcmd.Argument{
|
||
{Name: "output", Short: "o", Brief: "语料输出根目录(默认 " + defaultCorpusOutput + ")"},
|
||
{Name: "since", Brief: "起始时间,如 2026-09-01 或 2026-09-01T10:00:00Z"},
|
||
{Name: "until", Brief: "结束时间,同上"},
|
||
{Name: "channel", Brief: "渠道 ID 过滤,逗号分隔"},
|
||
{Name: "format", Brief: "客户端格式过滤:openai / claude / gemini / responses"},
|
||
{Name: "limit", Brief: fmt.Sprintf("最多扫描日志行数(默认 %d)", defaultScanLimit)},
|
||
{Name: "per-shape", Brief: "每种结构形状保留的样本数(默认 1)"},
|
||
{Name: "max-text", Brief: fmt.Sprintf("单个字符串值的脱敏填充长度上限(默认 %d,0=等长不截断)", defaultMaxText)},
|
||
{Name: "max-inline", Brief: fmt.Sprintf("内联 base64 数据超过该字节数则替换为占位,控制语料体积(默认 %d,0=不处理)", defaultMaxInline)},
|
||
{Name: "no-scrub", Brief: "关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量)", Orphan: true},
|
||
{Name: "keep-tool-names", Brief: "脱敏时保留自定义工具名原值(内置工具名始终保留)", Orphan: true},
|
||
{Name: "include-errors", Brief: "包含失败尝试(默认只取成功的最终尝试)", Orphan: true},
|
||
{Name: "dump-segments", Brief: "打印指定日志 ID(逗号分隔)的段1/段2报文片段,用于诊断"},
|
||
{Name: "dry-run", Brief: "只统计不落盘", Orphan: true},
|
||
},
|
||
Func: runCorpusExtract,
|
||
}
|
||
|
||
func runCorpusExtract(ctx context.Context, parser *gcmd.Parser) error {
|
||
output := corpusOutputDir(parser)
|
||
limit := parser.GetOpt("limit", defaultScanLimit).Int()
|
||
perShape := parser.GetOpt("per-shape", 1).Int()
|
||
maxInline := parser.GetOpt("max-inline", defaultMaxInline).Int()
|
||
noScrub := parser.GetOpt("no-scrub") != nil
|
||
keepToolNames := parser.GetOpt("keep-tool-names") != nil
|
||
includeErrors := parser.GetOpt("include-errors") != nil
|
||
dryRun := parser.GetOpt("dry-run") != nil
|
||
maxText := parser.GetOpt("max-text", fmt.Sprintf("%d", defaultMaxText)).Int()
|
||
channelFilter := parser.GetOpt("channel").String()
|
||
formatFilter := parser.GetOpt("format").String()
|
||
since := parser.GetOpt("since").String()
|
||
until := parser.GetOpt("until").String()
|
||
|
||
rows, err := queryDebugLogs(ctx, debugLogQuery{
|
||
since: since,
|
||
until: until,
|
||
channels: channelFilter,
|
||
format: formatFilter,
|
||
limit: limit,
|
||
includeErrors: includeErrors,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(rows) == 0 {
|
||
fmt.Println("未查询到符合条件的调试日志。请确认:")
|
||
fmt.Println(" 1) 目标渠道已开启 debug_log_enabled;")
|
||
fmt.Println(" 2) 采集窗口内确有流量;")
|
||
fmt.Println(" 3) --since / --until / --channel 过滤条件是否过严。")
|
||
return nil
|
||
}
|
||
|
||
scrubOpts := corpus.ScrubOptions{Enabled: !noScrub, KeepToolNames: keepToolNames, MaxTextLen: maxText}
|
||
collector := corpus.NewCollector(perShape, scrubOpts)
|
||
for _, row := range rows {
|
||
collector.Add(row.toRecord(maxInline))
|
||
}
|
||
|
||
if includeErrors {
|
||
printFailedAttempts(rows)
|
||
}
|
||
|
||
if ids := splitIDs(parser.GetOpt("dump-segments").String()); len(ids) > 0 {
|
||
printSegments(rows, ids)
|
||
}
|
||
|
||
samples := collector.Samples()
|
||
stats, err := corpus.Write(samples, collector.Stats(), corpus.WriteOptions{
|
||
OutputDir: output,
|
||
DryRun: dryRun,
|
||
Scrubbed: !noScrub,
|
||
Source: corpus.ManifestSource{
|
||
Since: since, Until: until,
|
||
Channel: channelFilter, Format: formatFilter,
|
||
Limit: limit,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
printCorpusSummary(output, stats, samples, dryRun, !noScrub)
|
||
return nil
|
||
}
|
||
|
||
// corpusOutputDir 语料输出目录:命令行 -o > 环境变量 TEAM_API_CORPUS_DIR > 默认仓库外目录。
|
||
func corpusOutputDir(parser *gcmd.Parser) string {
|
||
if v := parser.GetOpt("output").String(); v != "" {
|
||
return v
|
||
}
|
||
if v := os.Getenv("TEAM_API_CORPUS_DIR"); v != "" {
|
||
return v
|
||
}
|
||
return defaultCorpusOutput
|
||
}
|
||
|
||
// printFailedAttempts 打印采集窗口内全部请求的时间线,失败尝试附上游状态、
|
||
// 错误信息与响应体片段(--include-errors 时)。段3 错误体按设计不进语料
|
||
// (不可作为响应语料),但它是排查「客户端没反应」类问题的关键证据。
|
||
func printFailedAttempts(rows []debugLogRow) {
|
||
for _, r := range rows {
|
||
created := ""
|
||
if r.CreatedAt != nil {
|
||
created = r.CreatedAt.Time.Format("15:04:05")
|
||
}
|
||
fmt.Printf("%s log=%-4d %-18s ch=%-2d retry=%d us=%-3d cs=%-3d stream=%-5v %s→%s\n",
|
||
created, r.Id, r.ModelName, r.ChannelType, r.RetryIndex,
|
||
r.UpstreamStatusCode, r.ClientStatusCode, r.IsStream,
|
||
convClient(r.Conversion), convUpstream(r.Conversion))
|
||
if r.Error != "" {
|
||
fmt.Printf(" error: %s\n", truncateStr(r.Error, 300))
|
||
}
|
||
if r.UpstreamStatusCode != 200 && r.UpstreamStatusCode != 0 {
|
||
body := decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding)
|
||
fmt.Printf(" 段3: %s\n", truncateStr(string(body), 400))
|
||
}
|
||
}
|
||
}
|
||
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) > n {
|
||
return s[:n] + "..."
|
||
}
|
||
return s
|
||
}
|
||
|
||
// printSegments 打印指定日志的段2(发往上游的最终请求)结构化摘要,诊断转换结果用。
|
||
// 只打字段级信息不打原文:报文体动辄数十 KB(codex 系统提示),原文会淹没真正要看的参数区。
|
||
func printSegments(rows []debugLogRow, ids []string) {
|
||
wanted := make(map[string]bool, len(ids))
|
||
for _, id := range ids {
|
||
wanted[id] = true
|
||
}
|
||
for _, r := range rows {
|
||
if !wanted[fmt.Sprintf("%d", r.Id)] {
|
||
continue
|
||
}
|
||
fmt.Printf("===== log=%d %s %s→%s us=%d =====\n", r.Id, r.ModelName,
|
||
convClient(r.Conversion), convUpstream(r.Conversion), r.UpstreamStatusCode)
|
||
body := decodeBody(r.UpstreamReqBody, r.UpstreamReqEncoding)
|
||
var m map[string]any
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
fmt.Printf(" 段2 非 JSON(%v): %s\n", err, truncateStr(string(body), 200))
|
||
continue
|
||
}
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
fmt.Printf(" 顶层字段: %s\n", strings.Join(keys, ", "))
|
||
for _, k := range []string{"model", "max_tokens", "max_completion_tokens", "reasoning_effort", "temperature", "top_p", "stream"} {
|
||
if v, ok := m[k]; ok {
|
||
fmt.Printf(" %s: %v\n", k, v)
|
||
}
|
||
}
|
||
if v, ok := m["web_search_options"]; ok {
|
||
fmt.Printf(" web_search_options: %s\n", truncateStr(fmt.Sprint(v), 200))
|
||
} else {
|
||
fmt.Println(" web_search_options: <无>")
|
||
}
|
||
if tools, ok := m["tools"].([]any); ok {
|
||
types := map[string]int{}
|
||
for _, t := range tools {
|
||
if tm, ok := t.(map[string]any); ok {
|
||
if ty, _ := tm["type"].(string); ty != "" {
|
||
types[ty]++
|
||
}
|
||
}
|
||
}
|
||
fmt.Printf(" tools: %d 个 %v\n", len(tools), types)
|
||
} else {
|
||
fmt.Println(" tools: <无>")
|
||
}
|
||
if msgs, ok := m["messages"].([]any); ok {
|
||
roles := map[string]int{}
|
||
for _, msg := range msgs {
|
||
if mm, ok := msg.(map[string]any); ok {
|
||
if r, _ := mm["role"].(string); r != "" {
|
||
roles[r]++
|
||
}
|
||
}
|
||
}
|
||
fmt.Printf(" messages: %d 条 %v\n", len(msgs), roles)
|
||
}
|
||
}
|
||
}
|
||
|
||
func convClient(conversion string) string {
|
||
var c struct {
|
||
ClientFormat string `json:"client_format"`
|
||
}
|
||
_ = json.Unmarshal([]byte(conversion), &c)
|
||
return c.ClientFormat
|
||
}
|
||
|
||
func convUpstream(conversion string) string {
|
||
var c struct {
|
||
UpstreamFormat string `json:"upstream_format"`
|
||
}
|
||
_ = json.Unmarshal([]byte(conversion), &c)
|
||
return c.UpstreamFormat
|
||
}
|
||
|
||
func printCorpusSummary(output string, stats corpus.ManifestStats, samples []corpus.Sample, dryRun, scrubbed bool) {
|
||
fmt.Printf("\n扫描 %d 条日志 → %d 种结构形状 → %d 个语料文件\n",
|
||
stats.Scanned, stats.Shapes, stats.Written)
|
||
if stats.SkippedNoConv > 0 {
|
||
fmt.Printf(" 跳过 %d 条(缺 conversion 协议元数据,可能是非 relay 路径)\n", stats.SkippedNoConv)
|
||
}
|
||
if stats.SkippedEmpty > 0 {
|
||
fmt.Printf(" 跳过 %d 条(报文体为空或非 JSON)\n", stats.SkippedEmpty)
|
||
}
|
||
|
||
byDir := map[string]int{}
|
||
for _, s := range samples {
|
||
if s.Meta.ClientFormat != "" && s.Meta.UpstreamFormat != "" {
|
||
byDir[s.Meta.ClientFormat+"→"+s.Meta.UpstreamFormat]++
|
||
}
|
||
}
|
||
if len(byDir) > 0 {
|
||
fmt.Println("\n转换方向覆盖:")
|
||
for dir, n := range byDir {
|
||
fmt.Printf(" %-28s %d\n", dir, n)
|
||
}
|
||
fmt.Println("(方向覆盖不全时,补充对应渠道的调试采集再跑一次)")
|
||
}
|
||
|
||
if dryRun {
|
||
fmt.Println("\n[dry-run] 未写入任何文件")
|
||
return
|
||
}
|
||
fmt.Printf("\n语料已写入 %s/{real_inputs,real_stream_inputs,real_responses}\n", output)
|
||
fmt.Printf("清单:%s/corpus_manifest.json\n", output)
|
||
if scrubbed {
|
||
fmt.Println("\n语料已脱敏(自由文本等长替换、标识符一致映射、内联数据换占位图);" +
|
||
"脱敏是启发式的,提交前建议抽查确认。")
|
||
} else {
|
||
fmt.Println("\n⚠️ 语料为报文原文、未做脱敏,提交前请确认其中不含真实业务数据。")
|
||
}
|
||
}
|
||
|
||
// ---------- 数据库查询 ----------
|
||
|
||
type debugLogQuery struct {
|
||
since, until string
|
||
channels string
|
||
format string
|
||
limit int
|
||
includeErrors bool
|
||
}
|
||
|
||
// debugLogRow 与 chn_debug_logs 查询字段对应。
|
||
// (dump-segments 诊断需要段2 发往上游的最终请求体)
|
||
type debugLogRow struct {
|
||
Id int64 `orm:"id"`
|
||
RequestId string `orm:"request_id"`
|
||
ChannelType int `orm:"channel_type"`
|
||
ModelName string `orm:"model_name"`
|
||
UpstreamModel string `orm:"upstream_model"`
|
||
RelayMode string `orm:"relay_mode"`
|
||
InboundPath string `orm:"inbound_path"`
|
||
IsStream bool `orm:"is_stream"`
|
||
RetryIndex int `orm:"retry_index"`
|
||
UpstreamStatusCode int `orm:"upstream_status_code"`
|
||
ClientStatusCode int `orm:"client_status_code"`
|
||
Error string `orm:"error"`
|
||
ClientReqBody string `orm:"client_req_body"`
|
||
ClientReqEncoding string `orm:"client_req_encoding"`
|
||
UpstreamReqBody string `orm:"upstream_req_body"`
|
||
UpstreamReqEncoding string `orm:"upstream_req_encoding"`
|
||
UpstreamRespBody string `orm:"upstream_resp_body"`
|
||
UpstreamRespEncoding string `orm:"upstream_resp_encoding"`
|
||
Conversion string `orm:"conversion"`
|
||
CreatedAt *gtime.Time `orm:"created_at"`
|
||
}
|
||
|
||
func queryDebugLogs(ctx context.Context, q debugLogQuery) ([]debugLogRow, error) {
|
||
m := dao.ChnDebugLogs.Ctx(ctx).
|
||
Fields("id,request_id,channel_type,model_name,upstream_model,relay_mode,inbound_path,"+
|
||
"is_stream,retry_index,upstream_status_code,client_status_code,error,"+
|
||
"client_req_body,client_req_encoding,upstream_req_body,upstream_req_encoding,"+
|
||
"upstream_resp_body,upstream_resp_encoding,conversion,created_at").
|
||
// 只取最终尝试:中间失败尝试的段1 与最终尝试相同,段3 多为错误体
|
||
Where("is_final", true).
|
||
OrderDesc("id")
|
||
|
||
if !q.includeErrors {
|
||
// 只取上游成功的尝试:失败尝试的段3 是错误体,不是可用的响应语料
|
||
m = m.Where("upstream_status_code", 200)
|
||
}
|
||
if q.since != "" {
|
||
t, err := parseTimeFlag(q.since)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--since 时间格式无法解析: %w", err)
|
||
}
|
||
m = m.WhereGTE("created_at", t)
|
||
}
|
||
if q.until != "" {
|
||
t, err := parseTimeFlag(q.until)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("--until 时间格式无法解析: %w", err)
|
||
}
|
||
m = m.WhereLTE("created_at", t)
|
||
}
|
||
if ids := splitIDs(q.channels); len(ids) > 0 {
|
||
m = m.WhereIn("channel_id", ids)
|
||
}
|
||
if q.format != "" {
|
||
// conversion 是 JSONB,按 client_format 过滤
|
||
m = m.Where("conversion->>'client_format' = ?", q.format)
|
||
}
|
||
if q.limit > 0 {
|
||
m = m.Limit(q.limit)
|
||
}
|
||
|
||
var rows []debugLogRow
|
||
if err := m.Scan(&rows); err != nil {
|
||
return nil, fmt.Errorf("查询调试日志失败: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// toRecord 解码报文体并解析协议元数据。
|
||
// maxInline > 0 时把超长的 base64 内联数据替换为占位,避免单条语料膨胀到数 MB。
|
||
func (r debugLogRow) toRecord(maxInline int) corpus.Record {
|
||
rec := corpus.Record{
|
||
ID: r.Id,
|
||
RequestID: r.RequestId,
|
||
ChannelType: r.ChannelType,
|
||
ModelName: r.ModelName,
|
||
UpstreamModel: r.UpstreamModel,
|
||
RelayMode: r.RelayMode,
|
||
InboundPath: r.InboundPath,
|
||
IsStream: r.IsStream,
|
||
UpstreamStatus: r.UpstreamStatusCode,
|
||
|
||
ClientReqBody: decodeBody(r.ClientReqBody, r.ClientReqEncoding),
|
||
UpstreamRespBody: decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding),
|
||
}
|
||
if r.CreatedAt != nil {
|
||
rec.CapturedAt = r.CreatedAt.Time
|
||
}
|
||
if r.Conversion != "" {
|
||
_ = json.Unmarshal([]byte(r.Conversion), &rec.Conversion)
|
||
}
|
||
if maxInline > 0 {
|
||
rec.ClientReqBody = corpus.ShrinkInlineData(rec.ClientReqBody, maxInline)
|
||
rec.UpstreamRespBody = corpus.ShrinkInlineData(rec.UpstreamRespBody, maxInline)
|
||
}
|
||
return rec
|
||
}
|
||
|
||
// decodeBody 按 encoding 列还原原始字节(plain 直取、base64 解码)。
|
||
func decodeBody(body, encoding string) []byte {
|
||
if body == "" {
|
||
return nil
|
||
}
|
||
if encoding == "base64" {
|
||
if raw, err := base64.StdEncoding.DecodeString(body); err == nil {
|
||
return raw
|
||
}
|
||
return nil
|
||
}
|
||
return []byte(body)
|
||
}
|
||
|
||
func parseTimeFlag(s string) (*gtime.Time, error) {
|
||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||
// 无时区的格式按本地时区解释(time.Parse 会按 UTC,造成「白天的时间被当成晚上」
|
||
// 而查不到日志);RFC3339 自带时区偏移,Parse 与 ParseInLocation 行为一致
|
||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
||
return gtime.NewFromTime(t), nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("支持的格式:2006-01-02 / 2006-01-02 15:04:05 / RFC3339,实际收到 %q", s)
|
||
}
|
||
|
||
func splitIDs(s string) []string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, part := range strings.Split(s, ",") {
|
||
if p := strings.TrimSpace(part); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func init() {
|
||
if err := Main.AddCommand(&corpusExtractCmd); err != nil {
|
||
panic(err)
|
||
}
|
||
}
|