* 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):补充手写测试数据集
288 lines
11 KiB
Go
288 lines
11 KiB
Go
// Package relayconvert — 响应侧转换器 spec 类型与注册 / 查找机制。
|
|
//
|
|
// 本文件只移植「spec 类型 + 函数类型 + 注册 / 查找 / 别名」结构层。
|
|
// 调度引擎(ConvertResponse / ConvertStreamResponse / NewResponseStreamState* /
|
|
// ConvertStreamResponseChunk / FinalizeStreamResponse / execute* / infer* /
|
|
// canonicalUsageFromResponse)、ResponseStreamState 的方法(Usage/SetUsage/UsageText
|
|
// 依赖 stream-state 类型)以及全部 adapter 函数。
|
|
package relayconvert
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/qianfree/team-api/relaykit/dto"
|
|
"github.com/qianfree/team-api/relaykit/relayconvert/convmeta"
|
|
"github.com/qianfree/team-api/relaykit/types"
|
|
)
|
|
|
|
type ResponseConverterFunc func(c context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error)
|
|
|
|
type ResponseStreamConverterFunc func(c context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error)
|
|
|
|
type ResponseStreamStateFactory func(options ResponseStreamOptions) any
|
|
|
|
type ResponseStreamChunkConverterFunc func(c context.Context, info convmeta.Meta, response any, state any) ([]any, *dto.Usage, error)
|
|
|
|
type ResponseStreamFinalizerFunc func(c context.Context, info convmeta.Meta, state any) ([]any, *dto.Usage, error)
|
|
|
|
type ResponseConverterQuality string
|
|
|
|
const (
|
|
ResponseConverterQualityGood ResponseConverterQuality = "good"
|
|
ResponseConverterQualityFair ResponseConverterQuality = "fair"
|
|
ResponseConverterQualityDiscouraged ResponseConverterQuality = "discouraged"
|
|
)
|
|
|
|
type ResponseStep struct {
|
|
Converter string
|
|
From types.RelayFormat
|
|
To types.RelayFormat
|
|
}
|
|
|
|
type ResponseResult struct {
|
|
Value any
|
|
Usage *dto.Usage
|
|
From types.RelayFormat
|
|
To types.RelayFormat
|
|
Converter string
|
|
Quality ResponseConverterQuality
|
|
Steps []ResponseStep
|
|
Stream bool
|
|
}
|
|
|
|
type ResponseConverterSpec struct {
|
|
ID string
|
|
From types.RelayFormat
|
|
To types.RelayFormat
|
|
Quality ResponseConverterQuality
|
|
Convert ResponseConverterFunc
|
|
ConvertStream ResponseStreamConverterFunc
|
|
NewStreamState ResponseStreamStateFactory
|
|
ConvertStreamChunk ResponseStreamChunkConverterFunc
|
|
FinalizeStream ResponseStreamFinalizerFunc
|
|
StepConverters []string
|
|
}
|
|
|
|
type responseConverterRoute struct {
|
|
from types.RelayFormat
|
|
to types.RelayFormat
|
|
}
|
|
|
|
// ResponseStreamOptions 透传给 NewStreamState 工厂,用于初始化流式转换状态。
|
|
type ResponseStreamOptions struct {
|
|
ID string
|
|
Model string
|
|
Created int64
|
|
IncludeUsage bool
|
|
}
|
|
|
|
// ResponseStreamState 承载一次流式响应转换的跨 chunk 状态。
|
|
// 仅定义数据字段;其方法(Usage/SetUsage/UsageText/rememberUsage)与
|
|
// 构造器(NewResponseStreamState)依赖 stream-state 实现类型。
|
|
type ResponseStreamState struct {
|
|
From types.RelayFormat
|
|
To types.RelayFormat
|
|
Converter string
|
|
Quality ResponseConverterQuality
|
|
Steps []ResponseStep
|
|
|
|
specs []ResponseConverterSpec
|
|
stepStates []any
|
|
usage *dto.Usage
|
|
}
|
|
|
|
const (
|
|
ResponseConverterOAIChatToOAIResponses = "oai_chat_to_oai_responses_resp"
|
|
ResponseConverterOAIResponsesToOAIChat = "oai_responses_to_oai_chat_resp"
|
|
ResponseConverterOAIChatToClaudeMessages = "oai_chat_to_claude_messages_resp"
|
|
ResponseConverterOAIChatToGeminiChat = "oai_chat_to_gemini_chat_resp"
|
|
ResponseConverterClaudeMessagesToOAIChat = "claude_messages_to_oai_chat_resp"
|
|
ResponseConverterClaudeMessagesToOAIChatStream = "claude_messages_to_oai_chat_stream_resp"
|
|
ResponseConverterGeminiChatToOAIChat = "gemini_chat_to_oai_chat_resp"
|
|
ResponseConverterGeminiChatToOAIChatStream = "gemini_chat_to_oai_chat_stream_resp"
|
|
|
|
// 原生格式供应商 → OpenAI(响应侧,含流式)
|
|
ResponseConverterOllamaChatToOAIChat = "ollama_chat_to_oai_chat_resp"
|
|
ResponseConverterOllamaChatToOAIChatStream = "ollama_chat_to_oai_chat_stream_resp"
|
|
|
|
// 反向方向(OpenAI 上游 → 非 OpenAI 客户端)与 Responses 双向的流式转换器 ID。
|
|
// 流式注册表按数据方向 (from=上游格式, to=客户端格式) 登记。
|
|
ResponseConverterOAIChatToClaudeMessagesStream = "oai_chat_to_claude_messages_stream_resp"
|
|
ResponseConverterOAIChatToGeminiChatStream = "oai_chat_to_gemini_chat_stream_resp"
|
|
ResponseConverterOAIResponsesToOAIChatStream = "oai_responses_to_oai_chat_stream_resp"
|
|
ResponseConverterOAIChatToOAIResponsesStream = "oai_chat_to_oai_responses_stream_resp"
|
|
|
|
// 跨原生方向(上游原生 → 另一原生客户端)的非流式转换器 ID(响应侧元数据)。
|
|
ResponseConverterClaudeMessagesToGeminiChat = "claude_messages_to_gemini_chat_resp"
|
|
ResponseConverterGeminiChatToClaudeMessages = "gemini_chat_to_claude_messages_resp"
|
|
ResponseConverterClaudeMessagesToOAIResponses = "claude_messages_to_oai_responses_resp"
|
|
ResponseConverterGeminiChatToOAIResponses = "gemini_chat_to_oai_responses_resp"
|
|
|
|
// 跨原生方向(上游原生 → 另一原生客户端)的流式转换器 ID。
|
|
ResponseConverterClaudeMessagesToGeminiChatStream = "claude_messages_to_gemini_chat_stream_resp"
|
|
ResponseConverterGeminiChatToClaudeMessagesStream = "gemini_chat_to_claude_messages_stream_resp"
|
|
ResponseConverterGeminiChatToOAIResponsesStream = "gemini_chat_to_oai_responses_stream_resp"
|
|
ResponseConverterClaudeMessagesToOAIResponsesStream = "claude_messages_to_oai_responses_stream_resp"
|
|
)
|
|
|
|
var (
|
|
responseConverterMu sync.RWMutex
|
|
responseConverters = make(map[string]ResponseConverterSpec)
|
|
responseConverterAliases = make(map[string]string)
|
|
responseConverterRoutes = make(map[responseConverterRoute]string)
|
|
)
|
|
|
|
// ListResponseConverterIDs 返回全部已注册响应侧转换器 ID(字典序副本,不含别名)。
|
|
// 供诊断与测试枚举使用,与 ListRequestConverterIDs 配套。
|
|
func ListResponseConverterIDs() []string {
|
|
responseConverterMu.RLock()
|
|
defer responseConverterMu.RUnlock()
|
|
|
|
ids := make([]string, 0, len(responseConverters))
|
|
for id := range responseConverters {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
// registerBuiltinResponseConverter 注册响应侧转换器到注册表。
|
|
// init() 通过 registerBuiltinTextConverter() 调用本函数。
|
|
//
|
|
// 并发安全:持有 responseConverterMu 写锁保护注册过程,防止 data race。
|
|
// 虽然通常在包 init() 中调用(单线程),但加锁确保未来动态注册或测试并发场景安全。
|
|
func registerBuiltinResponseConverter(spec ResponseConverterSpec) {
|
|
spec.ID = strings.TrimSpace(spec.ID)
|
|
if spec.ID == "" {
|
|
panic("response converter ID is required")
|
|
}
|
|
if spec.From == "" || spec.To == "" {
|
|
panic(fmt.Sprintf("response converter %q must declare from and to formats", spec.ID))
|
|
}
|
|
if spec.Quality == "" {
|
|
panic(fmt.Sprintf("response converter %q must declare quality", spec.ID))
|
|
}
|
|
if spec.Convert == nil &&
|
|
spec.ConvertStream == nil &&
|
|
spec.ConvertStreamChunk == nil &&
|
|
len(spec.StepConverters) == 0 {
|
|
panic(fmt.Sprintf("response converter %q must declare convert, stream convert, or step converters", spec.ID))
|
|
}
|
|
if len(spec.StepConverters) > 0 &&
|
|
(spec.Convert != nil || spec.ConvertStream != nil || spec.NewStreamState != nil || spec.ConvertStreamChunk != nil || spec.FinalizeStream != nil) {
|
|
panic(fmt.Sprintf("response converter %q cannot declare direct implementations and step converters together", spec.ID))
|
|
}
|
|
|
|
responseConverterMu.Lock()
|
|
defer responseConverterMu.Unlock()
|
|
|
|
if _, exists := responseConverters[spec.ID]; exists {
|
|
panic(fmt.Sprintf("response converter %q is already registered", spec.ID))
|
|
}
|
|
route := responseConverterRoute{from: spec.From, to: spec.To}
|
|
if existingID, exists := responseConverterRoutes[route]; exists {
|
|
panic(fmt.Sprintf("response converter route from %s to %s is already registered by %q", spec.From, spec.To, existingID))
|
|
}
|
|
|
|
if len(spec.StepConverters) > 0 {
|
|
stepConverters := make([]string, 0, len(spec.StepConverters))
|
|
current := spec.From
|
|
for _, converterID := range spec.StepConverters {
|
|
step, ok := responseConverters[converterID]
|
|
if !ok {
|
|
panic(fmt.Sprintf("response converter %q references unknown step converter %q", spec.ID, converterID))
|
|
}
|
|
if len(step.StepConverters) > 0 {
|
|
panic(fmt.Sprintf("response converter %q step %q must be a direct converter", spec.ID, converterID))
|
|
}
|
|
if step.From != current {
|
|
panic(fmt.Sprintf("response converter %q step %q expects %s after %s", spec.ID, converterID, step.From, current))
|
|
}
|
|
stepConverters = append(stepConverters, converterID)
|
|
current = step.To
|
|
}
|
|
if current != spec.To {
|
|
panic(fmt.Sprintf("response converter %q ends at %s, expected %s", spec.ID, current, spec.To))
|
|
}
|
|
spec.StepConverters = stepConverters
|
|
}
|
|
|
|
responseConverters[spec.ID] = spec
|
|
responseConverterRoutes[route] = spec.ID
|
|
}
|
|
|
|
// registerResponseConverterAlias 注册响应转换器别名。
|
|
//
|
|
// 并发安全:持有 responseConverterMu 写锁保护注册过程,防止 data race。
|
|
// 虽然通常在包 init() 中调用(单线程),但加锁确保未来动态注册或测试并发场景安全。
|
|
func registerResponseConverterAlias(alias string, converter string) {
|
|
alias = strings.TrimSpace(alias)
|
|
converter = strings.TrimSpace(converter)
|
|
if alias == "" {
|
|
panic("response converter alias is required")
|
|
}
|
|
if converter == "" {
|
|
panic(fmt.Sprintf("response converter alias %q target is required", alias))
|
|
}
|
|
if alias == converter {
|
|
return
|
|
}
|
|
|
|
responseConverterMu.Lock()
|
|
defer responseConverterMu.Unlock()
|
|
|
|
if _, exists := responseConverters[alias]; exists {
|
|
panic(fmt.Sprintf("response converter alias %q conflicts with registered converter", alias))
|
|
}
|
|
if _, exists := responseConverters[converter]; !exists {
|
|
panic(fmt.Sprintf("response converter alias %q references unknown converter %q", alias, converter))
|
|
}
|
|
if existing, exists := responseConverterAliases[alias]; exists && existing != converter {
|
|
panic(fmt.Sprintf("response converter alias %q is already registered for %q", alias, existing))
|
|
}
|
|
responseConverterAliases[alias] = converter
|
|
}
|
|
|
|
func LookupResponseConverter(converter string) (ResponseConverterSpec, bool) {
|
|
responseConverterMu.RLock()
|
|
defer responseConverterMu.RUnlock()
|
|
|
|
converterID := resolveResponseConverterID(converter)
|
|
spec, ok := responseConverters[converterID]
|
|
if !ok {
|
|
return ResponseConverterSpec{}, false
|
|
}
|
|
return cloneResponseConverterSpec(spec), true
|
|
}
|
|
|
|
func lookupResponseRoute(from types.RelayFormat, to types.RelayFormat) (ResponseConverterSpec, bool) {
|
|
responseConverterMu.RLock()
|
|
defer responseConverterMu.RUnlock()
|
|
|
|
converterID, ok := responseConverterRoutes[responseConverterRoute{from: from, to: to}]
|
|
if !ok {
|
|
return ResponseConverterSpec{}, false
|
|
}
|
|
spec, ok := responseConverters[converterID]
|
|
return cloneResponseConverterSpec(spec), ok
|
|
}
|
|
|
|
func resolveResponseConverterID(converter string) string {
|
|
converter = strings.TrimSpace(converter)
|
|
if canonical, ok := responseConverterAliases[converter]; ok {
|
|
return canonical
|
|
}
|
|
return converter
|
|
}
|
|
|
|
func cloneResponseConverterSpec(spec ResponseConverterSpec) ResponseConverterSpec {
|
|
if len(spec.StepConverters) > 0 {
|
|
spec.StepConverters = append([]string{}, spec.StepConverters...)
|
|
}
|
|
return spec
|
|
}
|