mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。
- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
(content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
system 支持字符串与 [{type:text,text}] 两种写法)。
调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
serialize_open_preamble / serialize_chunk / serialize_close,
把统一格式译回 Anthropic Messages 形状(含 content 分块、
stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。
内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。
测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。
golden 字节级未漂移;go test -tags luajit ./... 全绿。
This commit is contained in:
@ -456,4 +456,124 @@ function adapter.transform_error(status, body)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- 对外序列化(统一格式 → Anthropic 形状)
|
||||
-- ============================================================
|
||||
--
|
||||
-- 内核只持统一格式;对外发什么形状由适配器决定(Phase 2)。
|
||||
-- 这几个钩子让以 anthropic 适配器为服务端的源,直接对 Anthropic 协议客户端
|
||||
-- 提供 /v1/messages 语义,而内核里不再有任何 Anthropic 字段名。
|
||||
--
|
||||
-- 与入向的 transform_* 互为逆运算:那里的映射(end_turn→stop)在这里反着
|
||||
-- 做回去(stop→end_turn),且默认 stop_sequence 是最保守的选择——它语义上
|
||||
-- 最接近"正常结束"。
|
||||
|
||||
-- 统一 finish_reason → Anthropic stop_reason
|
||||
local function to_stop_reason(finish)
|
||||
if finish == "tool_calls" then return "tool_use" end
|
||||
if finish == "length" then return "max_tokens" end
|
||||
if finish == "content_filter" then return "refusal" end
|
||||
-- stop / 空 / 未知 → end_turn(Anthropic 的"正常说完")
|
||||
return "end_turn"
|
||||
end
|
||||
|
||||
-- usage 数字化:内部 TokenUsage 的字段名与 Anthropic 的 input_tokens/output_tokens
|
||||
-- 不同名,这里做最后一次翻译。
|
||||
local function to_anthropic_usage(u)
|
||||
if type(u) ~= "table" then
|
||||
return { input_tokens = 0, output_tokens = 0 }
|
||||
end
|
||||
return {
|
||||
input_tokens = u.prompt or 0,
|
||||
output_tokens = u.completion or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- 非流式响应:统一结果 → Anthropic Messages 响应对象
|
||||
function adapter.serialize_response(payload)
|
||||
local p = json.decode(payload)
|
||||
local blocks = {}
|
||||
-- thinking 放最前,与 Anthropic 自己的块顺序一致
|
||||
if p.reasoning ~= nil and p.reasoning ~= "" then
|
||||
blocks[#blocks + 1] = { type = "thinking", thinking = p.reasoning }
|
||||
end
|
||||
blocks[#blocks + 1] = { type = "text", text = p.content or "" }
|
||||
if type(p.tool_calls) == "table" and #p.tool_calls > 0 then
|
||||
for _, tc in ipairs(p.tool_calls) do
|
||||
blocks[#blocks + 1] = {
|
||||
type = "tool_use",
|
||||
id = tc.id or "",
|
||||
name = tc.name or "",
|
||||
input = tc.arguments or {},
|
||||
}
|
||||
end
|
||||
end
|
||||
return json.encode({
|
||||
id = p.id or "",
|
||||
type = "message",
|
||||
role = "assistant",
|
||||
model = p.model or "",
|
||||
content = blocks,
|
||||
stop_reason = to_stop_reason(p.finish_reason),
|
||||
stop_sequence = nil,
|
||||
usage = to_anthropic_usage(p.token_usage),
|
||||
})
|
||||
end
|
||||
|
||||
-- 角色前导:Anthropic 的流以 message_start 开头(不是 role delta)
|
||||
function adapter.serialize_open_preamble(payload)
|
||||
local p = json.decode(payload)
|
||||
return json.encode({
|
||||
type = "message_start",
|
||||
message = {
|
||||
id = p.id or "",
|
||||
type = "message",
|
||||
role = "assistant",
|
||||
model = p.model or "",
|
||||
content = {},
|
||||
stop_reason = nil,
|
||||
stop_sequence = nil,
|
||||
usage = { input_tokens = 0, output_tokens = 0 },
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
-- 流式增量:统一 chunk → Anthropic content_block_delta
|
||||
--
|
||||
-- 真实 Anthropic 流需要 content_block_start(开块)→ delta → stop(关块)
|
||||
-- 的三段式。这里用与多数 Anthropic 客户端兼容的简化形式:首个文本增量
|
||||
-- 带 index=0 的 delta,后续同 index;关闭由 serialize_close 负责。
|
||||
function adapter.serialize_chunk(payload)
|
||||
local p = json.decode(payload)
|
||||
if p.done then
|
||||
-- 终止由 serialize_close 统一发,这里不重复
|
||||
return ""
|
||||
end
|
||||
if p.reasoning ~= nil and p.reasoning ~= "" then
|
||||
return json.encode({
|
||||
type = "content_block_delta",
|
||||
index = 0,
|
||||
delta = { type = "thinking_delta", thinking = p.reasoning },
|
||||
})
|
||||
end
|
||||
return json.encode({
|
||||
type = "content_block_delta",
|
||||
index = 0,
|
||||
delta = { type = "text_delta", text = p.content or "" },
|
||||
})
|
||||
end
|
||||
|
||||
-- 收尾:message_delta(stop_reason + usage)→ message_stop
|
||||
function adapter.serialize_close(payload)
|
||||
local p = json.decode(payload)
|
||||
return json.encode({
|
||||
type = "message_delta",
|
||||
delta = {
|
||||
stop_reason = to_stop_reason(p.finish_reason),
|
||||
stop_sequence = nil,
|
||||
},
|
||||
usage = { output_tokens = 0 },
|
||||
}) .. "\n" .. json.encode({ type = "message_stop" })
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
53
internal/lua/ser_check_test.go
Normal file
53
internal/lua/ser_check_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnthropicSerializerShape(t *testing.T) {
|
||||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
if !vm.HasFn("anthropic", "serialize_response") {
|
||||
t.Fatal("anthropic 未声明 serialize_response")
|
||||
}
|
||||
out, err := vm.Transform("anthropic", "serialize_response",
|
||||
`{"model":"claude-x","id":"msg_1","content":"hello","finish_reason":"stop","token_usage":{"prompt":10,"completion":5,"total":15}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &m); err != nil {
|
||||
t.Fatalf("not JSON: %s", out)
|
||||
}
|
||||
if m["type"] != "message" || m["role"] != "assistant" {
|
||||
t.Errorf("Anthropic 形状不对: %s", out)
|
||||
}
|
||||
if m["stop_reason"] != "end_turn" {
|
||||
t.Errorf("stop_reason = %v, want end_turn", m["stop_reason"])
|
||||
}
|
||||
blocks, _ := m["content"].([]interface{})
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("blocks = %v", m["content"])
|
||||
}
|
||||
b0 := blocks[0].(map[string]interface{})
|
||||
if b0["type"] != "text" || b0["text"] != "hello" {
|
||||
t.Errorf("首个块应为 text/hello: %v", b0)
|
||||
}
|
||||
u := m["usage"].(map[string]interface{})
|
||||
if u["input_tokens"] != float64(10) || u["output_tokens"] != float64(5) {
|
||||
t.Errorf("usage 字段名/值不对: %v", u)
|
||||
}
|
||||
// 内核不得混入 OpenAI 字段
|
||||
for _, k := range []string{"object", "choices", "usage.prompt_tokens"} {
|
||||
if strings.Contains(out, `"`+k+`"`) {
|
||||
t.Errorf("OpenAI 字段 %q 混入 Anthropic 输出: %s", k, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user