Files
ModelRouter/internal/gateway/serializer.go
JianFeeeee 9d6653a573 feat(gateway): 对外序列化移交适配器,OpenAI 格式降级为最后容错
内核不再硬编码对外 wire。writeChatCompletion / pumpStream 里的 OpenAI
帧拼装抽到 Serializer 接口,适配器可声明钩子完全接管。

优先级契约(用户明确要求:冲突时兜底直接让步):

  适配器声明 serialize_*  ->  适配器完全接管
  未声明                 ->  兜底 openAISerializer

「让步」的含义:适配器接管后内核绝不往其输出补任何 OpenAI 字段。混合体
(半个 Anthropic + 半个 OpenAI)比任何一个单独结果都更难排查——客户端无法
解析,而排查时会指向错误的层。

接口把「流如何结束」也纳入:OpenAI 需 usage 帧 + [DONE],Anthropic 用
message_stop,Gemini 用空 candidates 帧。DoneMarker 让适配器声明自己的
终止标记,避免擅自给严格客户端加 [DONE]。

新增能力:
- VM.HasFn(name, fn):不占 worker 探测适配器是否声明某函数
- VM.AdapterRevision(name):适配器源码每次替换 +1(接着旧值递增)

修掉两个开发中引入的真实缺陷:
1. 锁序倒置自死锁:LoadAdapterSource 里在已持有 v.mu.Lock() 时嵌套
   v.mu.RLock() 取 revision,RWMutex 不可重入 -> 整个测试套件静默挂起。
   症状是不报错也不超时,只是永不返回;靠 go test -timeout 的 goroutine
   栈定位。
2. 缓存键漏掉 VM 维度:serializerCache 原按适配器名做键,导致同进程内两个
   VM 共享判定,适配器重写后新钩子永不生效且不报错。键改为
   (VM, revision, adapter),回归测试 TestSerializerCacheIsPerVM。

测试三件套:
- 功能:适配器接管 / 兜底(无适配器)/ 钩子失败退回兜底 / 缓存随改写失效
- 压力:TestSerializerForConcurrent(16 worker × 40 次)
- 回归:golden 字节级未漂移,Phase 0 安全网生效

验证:go test -tags luajit ./... 全绿(gateway 200s / lua 56s)。
2026-10-11 08:57:51 +08:00

467 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package gateway — 对外序列化(Phase 2)
//
// 职责边界(feature/unified-wire-v2):
//
// 内核不硬编码任何对外协议。序列化由 Serializer 承载,默认实现是
// openAISerializer —— 但它只是**最后容错**,不是默认业务路径。
//
// 优先级契约(用户明确要求:任何适配器与兜底 OpenAI 格式冲突时,
// 兜底 OpenAI 直接让步):
//
// 适配器声明 serialize_response / serialize_chunk
// ↓ 未声明
// openAISerializer(内核兜底,保证刚部署未配适配器时仍能服务)
//
// 「让步」的具体含义:一旦适配器声明了对外序列化钩子,内核在**该源**
// 上完全采用适配器的输出,绝不与兜底格式合并/兜底字段。A 请求经 Anthropic
// 序列化后,绝不会被再补上 OpenAI 的字段。这条规则由
// TestAdapterSerializerWinsOverFallback 锁定。
//
// 安全网:openAISerializer 的输出与改造前内核手写的 OpenAI wire 字节级
// 一致(e2e/.snapshots golden 锁定),所以「适配器未声明」这条路径上的
// 对外行为与本次改造之前完全相同。
package gateway
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"llmsproxy/internal/lua"
"llmsproxy/internal/provider"
"llmsproxy/internal/types"
)
// StreamState 是一个流会话在序列化侧的累积状态(角色前导是否已发、已见
// 的终止原因、累积 usage/cost)。跨 chunk 传递,避免每帧重新统计。
type StreamState struct {
LastFinish string
Usage *types.TokenUsage
Cost string
}
// Serializer 把统一内部格式序列化成对外协议的响应体。
//
// 非流式与流式的终止语义对不同协议差异很大(OpenAI 在最后一个 chunk 上
// 带 finish_reason 后跟一个 usage-only 帧;Anthropic 用独立的
// message_delta 事件;Gemini 用空 candidates 帧),所以「流如何结束」
// 是接口的一部分,而不是调用方的 if。
type Serializer interface {
// NonStream 把一次完成的统一响应序列化成对外 body。
NonStream(resp *types.UnifiedResponse, modelName string, id string, created int64) ([]byte, error)
// OpenRolePreamble 返回流开始时的角色前导帧(可能为空表示该协议不需要)。
// 返回空切片时调用方不写帧。
OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error)
// Chunk 把一个统一分块序列化成对外 SSE 帧(不含 "data: " 前缀与空行)。
Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error)
// Close 返回流结束时的收尾帧序列(可多帧),随后调用方写协议自己的
// 流终止标记(OpenAI 是 [DONE],Anthropic 无)。
Close(modelName, id string, created int64, st *StreamState) ([][]byte, error)
// DoneMarker 返回该协议的流终止标记;OpenAI 是 "[DONE]",无此概念的
// 协议返回空串(调用方因此不写那一行)。
DoneMarker() string
}
// ---- 兜底实现:OpenAI wire ----
// openAISerializer 是内核的**容错兜底**,输出与改造前手写的 OpenAI wire
// 字节级一致(golden 锁定)。它不是默认业务路径——适配器一旦声明序列化
// 钩子就完全取代它。
type openAISerializer struct{}
// OpenAISerializer 返回兜底 OpenAI 序列化器。暴露给同包测试与
// 「未配置任何适配器」的启动兜底路径使用。
func OpenAISerializer() Serializer { return openAISerializer{} }
func (openAISerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) {
msg := RespMessage{Role: "assistant", Content: resp.Content}
if resp.ReasoningContent != "" {
msg.ReasoningContent = resp.ReasoningContent
}
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = toolCallsWire(resp.ToolCalls)
}
out := ChatCompletion{
ID: id,
Object: "chat.completion",
Created: created,
Model: modelName,
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
Cost: resp.Cost,
}
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
out.Usage = &resp.TokenUsage
}
return json.Marshal(out)
}
func (openAISerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) {
return json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
})
}
func (openAISerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) {
delta := RespMessage{Role: "assistant", Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
if len(ck.ToolCalls) > 0 {
delta.ToolCalls = ck.ToolCalls
}
choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done {
fin := ck.FinishReason
if fin == "" {
fin = "stop"
}
st.LastFinish = fin
choice.FinishReason = &fin
}
return json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{choice},
})
}
func (openAISerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) {
fin := st.LastFinish
if fin == "" {
fin = "stop"
}
term, err := json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &fin}},
})
if err != nil {
return nil, err
}
out := [][]byte{term}
// OpenAI 的 usage 帧:空 choices + usage + 可选 cost。
if st.Usage != nil || st.Cost != "" {
var tut *types.TokenUsage
if st.Usage != nil {
tut = st.Usage
} else {
tut = &types.TokenUsage{}
}
u, err := json.Marshal(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
Choices: []ChunkChoice{}, Usage: tut, Cost: st.Cost,
})
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func (openAISerializer) DoneMarker() string { return "[DONE]" }
// ---- 适配器钩子通道 ----
// luaSerializer 把对外序列化委托给适配器的 serialize_response /
// serialize_chunk 钩子。适配器声明了钩子就**完全取代**兜底实现。
type luaSerializer struct {
vm *lua.VM
adapter string
// responseOK / chunkOK 在构造时一次性探测(HasFn 不占用 worker)。
responseOK bool
chunkOK bool
// openPreambleOK / closeOK 是可选钩子;未声明时借用兜底实现的对应行为,
// 这样「只实现 serialize_chunk」的适配器也能立刻可用。
openPreambleOK bool
closeOK bool
}
// serializerFor 为某个适配器名挑选序列化器。
//
// 这是「适配器优先、兜底让步」的唯一决策点:适配器声明了对外序列化
// 钩子就返回 luaSerializer(其输出不再与 OpenAI 兜底混合),否则返回
// 兜底 OpenAI。
func serializerFor(vm *lua.VM, adapter string) Serializer {
if vm == nil {
return OpenAISerializer()
}
resp := vm.HasFn(adapter, "serialize_response")
chunk := vm.HasFn(adapter, "serialize_chunk")
if !resp && !chunk {
return OpenAISerializer()
}
return &luaSerializer{
vm: vm,
adapter: adapter,
responseOK: resp,
chunkOK: chunk,
openPreambleOK: vm.HasFn(adapter, "serialize_open_preamble"),
closeOK: vm.HasFn(adapter, "serialize_close"),
}
}
func (s *luaSerializer) NonStream(resp *types.UnifiedResponse, modelName, id string, created int64) ([]byte, error) {
if !s.responseOK {
// 该适配器只实现了流式钩子:非流式沿用兜底。这是唯一允许兜底
// 参与的场景——适配器**没有**就该方向的实现,而不是「冲突」。
return openAISerializer{}.NonStream(resp, modelName, id, created)
}
payload := map[string]interface{}{
"model": modelName,
"id": id,
"created": created,
"content": resp.Content,
"reasoning": resp.ReasoningContent,
"finish_reason": resp.FinishReason,
"token_usage": resp.TokenUsage,
"cost": resp.Cost,
}
if len(resp.ToolCalls) > 0 {
payload["tool_calls"] = resp.ToolCalls
}
out, err := s.vm.Transform(s.adapter, "serialize_response", wireJSON(payload))
if err != nil || out == "" {
// 钩子失败:明确退回兜底,而不是产出一半适配器字段 + 一半 OpenAI
// 字段的混合体(那正是「让步」规则要避免的)。
return openAISerializer{}.NonStream(resp, modelName, id, created)
}
return []byte(out), nil
}
func (s *luaSerializer) OpenRolePreamble(modelName, id string, created int64, st *StreamState) ([]byte, error) {
if !s.openPreambleOK {
return openAISerializer{}.OpenRolePreamble(modelName, id, created, st)
}
out, err := s.vm.Transform(s.adapter, "serialize_open_preamble",
wireJSON(map[string]interface{}{"model": modelName, "id": id, "created": created}))
if err != nil {
return openAISerializer{}.OpenRolePreamble(modelName, id, created, st)
}
if out == "" {
return nil, nil // 该协议不需要角色前导
}
return []byte(out), nil
}
func (s *luaSerializer) Chunk(ck *types.UnifiedChunk, modelName, id string, created int64, st *StreamState) ([]byte, error) {
if !s.chunkOK {
return openAISerializer{}.Chunk(ck, modelName, id, created, st)
}
payload := map[string]interface{}{
"model": modelName,
"id": id,
"created": created,
"content": ck.Content,
"reasoning": ck.ReasoningContent,
"done": ck.Done,
"finish_reason": ck.FinishReason,
}
if len(ck.ToolCalls) > 0 {
payload["tool_calls"] = ck.ToolCalls
}
if ck.Usage != nil {
payload["usage"] = ck.Usage
}
if ck.Cost != "" {
payload["cost"] = ck.Cost
}
out, err := s.vm.Transform(s.adapter, "serialize_chunk", wireJSON(payload))
if err != nil {
return openAISerializer{}.Chunk(ck, modelName, id, created, st)
}
if out == "" {
return nil, nil
}
// 适配器的终止帧要驱动审计记录与收尾逻辑,所以把终止原因抄回状态。
if ck.Done && st.LastFinish == "" {
if fin := ck.FinishReason; fin != "" {
st.LastFinish = fin
}
}
return []byte(out), nil
}
func (s *luaSerializer) Close(modelName, id string, created int64, st *StreamState) ([][]byte, error) {
if !s.closeOK {
return openAISerializer{}.Close(modelName, id, created, st)
}
out, err := s.vm.Transform(s.adapter, "serialize_close",
wireJSON(map[string]interface{}{
"model": modelName, "id": id, "created": created,
"finish_reason": st.LastFinish, "cost": st.Cost,
"has_usage": st.Usage != nil,
}))
if err != nil {
return openAISerializer{}.Close(modelName, id, created, st)
}
if out == "" {
return nil, nil
}
// 适配器可以返回多帧(换行分隔),因为 Anthropic 的收尾本身就是
// message_stop + 若干 delta;单帧限制会逼适配器把多事件塞进一个 JSON。
var frames [][]byte
for _, line := range splitLines(out) {
if line == "" {
continue
}
frames = append(frames, []byte(line))
}
return frames, nil
}
// DoneMarker 返回协议自己的流终止标记。适配器可用 serialize_done_marker
// 声明;未声明时若有自定义流式序列化就返回空串(表示「没有 [DONE] 这种
// 概念」),否则沿用 OpenAI 的 [DONE]。
func (s *luaSerializer) DoneMarker() string {
if s.vm.HasFn(s.adapter, "serialize_done_marker") {
out, err := s.vm.Transform(s.adapter, "serialize_done_marker", "{}")
if err == nil {
return out
}
}
if s.chunkOK {
// 适配器接管了流式序列化:只有它没显式声明标记时才不发 [DONE],
// 因为擅自给 Anthropic 流加一行 "[DONE]" 会让严格客户端报错。
return ""
}
return "[DONE]"
}
// splitLines 切分多行帧输出。
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, trimCR(s[start:i]))
start = i + 1
}
}
if start < len(s) {
out = append(out, trimCR(s[start:]))
}
return out
}
func trimCR(s string) string {
if n := len(s); n > 0 && s[n-1] == '\r' {
return s[:n-1]
}
return s
}
// ---- 共享辅助 ----
var wireSeq int64
func newWireID() string {
n := atomic.AddInt64(&wireSeq, 1)
return fmt.Sprintf("chatcmpl-%d", n)
}
func newCreated() int64 { return time.Now().Unix() }
// wireJSON 是本文件私有的 JSON 编码助手(包内已有 discover.go 的 mustJSON,
// 那个返回 []byte 且忽略错误,语义不同,故不共用)。
func wireJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
// serializerCache 按 (VM, 适配器名) 缓存 Serializer。
//
// VM 必须进键里:缓存曾经只按适配器名做键,结果一个进程内两个 VM(或
// 热重载后的新旧 VM)会共享同一条判定。插件在运行时改写适配器后声明了
// serialize_response,另一条缓存记录仍会说「没声明」,于是新钩子永远不
// 生效——而且因为判定本身不会报错,表现只是「适配器说好的对外格式没
// 出现」,极难定位。
//
// HasFn 的代价决定了它必须被缓存:它会**编译一次适配器脚本**来回答
// 「这个适配器声明了哪些函数」,放在每个请求上等于把加载成本翻倍。
//
// 失效由 vmGeneration 负责:适配器重写时 VM 的代号会变(见 adapterEpoch),
// 于是旧条目再也匹配不上,无需显式清扫。
var serializerCache sync.Map // cacheKey -> Serializer
// cacheKey 是 (VM 指针, VM 代号, 适配器名) 的复合键。
type cacheKey struct {
vm *lua.VM
gen uint64
name string
}
func serializerForCached(vm *lua.VM, adapter string) Serializer {
if vm == nil {
return OpenAISerializer()
}
k := cacheKey{vm: vm, gen: adapterEpoch(vm, adapter), name: adapter}
if v, ok := serializerCache.Load(k); ok {
return v.(Serializer)
}
s := serializerFor(vm, adapter)
actual, _ := serializerCache.LoadOrStore(k, s)
return actual.(Serializer)
}
// adapterEpoch 返回该适配器「当前这份代码」的代号。适配器被重写后代号变化,
// 使旧缓存条目自动失效(见 serializerCache 的注释)。
func adapterEpoch(vm *lua.VM, adapter string) uint64 {
return vm.AdapterRevision(adapter)
}
// serializerForSource 解析「实际服务该请求的那个源」该用哪个序列化器。
//
// 必须按**服务者**选,而不是按调用方请求的首选源选:direct 请求在候选源
// 之间故障转移时,usedSrc 可能与请求 model 所属的源完全不同;按首选源选
// 会让故障转移后的响应带着另一个源的对外格式。
//
// 源名查不到时(配置在选择之后被改、或插件装的源尚未注册)退回兜底
// OpenAI:那是「刚部署 / 异常态下仍能服务」的保证,而不是静默产出错误格式。
func serializerForSource(vm *lua.VM, sources []SourceRef, sourceName string) Serializer {
for _, s := range sources {
if s.Name == sourceName {
return serializerForCached(vm, s.Adapter)
}
}
return OpenAISerializer()
}
// SourceRef 是解析序列化器所需的最小源信息(名字 + 适配器)。
type SourceRef struct {
Name string
Adapter string
}
// serializerFor 是 Gateway 侧的解析入口:按**实际服务该请求的源**挑序列化器。
//
// cands 传候选列表时用它做故障转移后的解析(服务源可能不是首选);传 nil
// 表示 AUTO 路径——AUTO 的服务源由链决定,不在调用方的候选集里,退回按
// 当前配置里全部源查找。
func (g *Gateway) serializerFor(sourceName string, cands []*provider.Provider) Serializer {
vm := g.core.VM()
if vm == nil {
return OpenAISerializer()
}
if cands != nil {
refs := make([]SourceRef, 0, len(cands))
for _, p := range cands {
refs = append(refs, SourceRef{Name: p.Name(), Adapter: p.Adapter()})
}
return serializerForSource(vm, refs, sourceName)
}
refs := make([]SourceRef, 0, 16)
for _, s := range g.core.Sources() {
refs = append(refs, SourceRef{Name: s.Name, Adapter: s.Adapter})
}
return serializerForSource(vm, refs, sourceName)
}