mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk)
Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着:
/v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified),
Anthropic 改协议就得改内核。本次补齐这条对偶。
## 机制(与出向序列化对称)
- 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现
deserialize_request(raw_body) -> unified_json。
- 内核 handleMessages 经新增的 parseViaIngress 判定:
- 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成;
- 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。
- 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析
(客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。
- 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。
## 内核改动
- internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取;
新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。
- internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress
(让出)→ 未认领时回落内置 anthropicRequest.toUnified。
内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。
## 适配器改动
- internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages",
新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、
content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。
内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它,
Go 侧 anthropicRequest.toUnified 退化为纯兜底。
## 测试
- gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、
TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、
TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。
- lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致
(system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、
缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。
- 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。
- 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。
149 lines
4.9 KiB
Go
149 lines
4.9 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAnthropicDeserializeRequestShape 验证 anthropic 适配器的 ingress 解析
|
||
// (deserialize_request)输出与内核原 Go 兜底(anthropicRequest.toUnified)逐字段
|
||
// 一致。这是"让出"的前提:若两边产出不同,"谁解析"就会悄悄改变行为。
|
||
func TestAnthropicDeserializeRequestShape(t *testing.T) {
|
||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer vm.Stop()
|
||
|
||
// 适配器必须声明 ingress 并提供钩子,内核才会让出。
|
||
if !vm.HasFn("anthropic", "deserialize_request") {
|
||
t.Fatal("anthropic 未声明 deserialize_request")
|
||
}
|
||
|
||
// system 两种拼写 + content 字符串/块数组 + thinking 缺省
|
||
in := `{
|
||
"model": "claude-x",
|
||
"max_tokens": 128,
|
||
"system": [{"type":"text","text":"be terse"},{"type":"text","text":"!"}],
|
||
"messages": [
|
||
{"role":"user","content":"hi"},
|
||
{"role":"assistant","content":[{"type":"text","text":"hello"}]}
|
||
],
|
||
"temperature": 0.5,
|
||
"top_p": 0.9,
|
||
"top_k": 40,
|
||
"stop_sequences": ["STOP"],
|
||
"stream": true
|
||
}`
|
||
out, err := vm.Transform("anthropic", "deserialize_request", in)
|
||
if err != nil {
|
||
t.Fatalf("deserialize_request: %v", err)
|
||
}
|
||
var u struct {
|
||
Model string `json:"model"`
|
||
System string `json:"system"`
|
||
Messages []json.RawMessage `json:"messages"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
Stream bool `json:"stream"`
|
||
Temperature *float64 `json:"temperature"`
|
||
TopP *float64 `json:"top_p"`
|
||
TopK int `json:"top_k"`
|
||
StopSequences []string `json:"stop_sequences"`
|
||
Thinking *struct {
|
||
Enabled *bool `json:"enabled"`
|
||
} `json:"thinking"`
|
||
}
|
||
if err := json.Unmarshal([]byte(out), &u); err != nil {
|
||
t.Fatalf("not JSON: %s", out)
|
||
}
|
||
|
||
if u.Model != "claude-x" {
|
||
t.Errorf("model = %q", u.Model)
|
||
}
|
||
// Go 侧 systemToString 把 [{text:"be terse"},{text:"!"}] 拼成 "be terse!"
|
||
// (text 块按序拼接,无分隔符)。Lua 必须一致。
|
||
if u.System != "be terse!" {
|
||
t.Errorf("system = %q, want %q (Go 侧拼接语义)", u.System, "be terse!")
|
||
}
|
||
if len(u.Messages) != 2 {
|
||
t.Fatalf("messages = %d", len(u.Messages))
|
||
}
|
||
if u.MaxTokens != 128 || !u.Stream {
|
||
t.Errorf("max_tokens/stream = %d/%v", u.MaxTokens, u.Stream)
|
||
}
|
||
if u.Temperature == nil || *u.Temperature != 0.5 {
|
||
t.Errorf("temperature = %v", u.Temperature)
|
||
}
|
||
if u.TopP == nil || *u.TopP != 0.9 || u.TopK != 40 {
|
||
t.Errorf("top_p/top_k = %v/%d", u.TopP, u.TopK)
|
||
}
|
||
if len(u.StopSequences) != 1 || u.StopSequences[0] != "STOP" {
|
||
t.Errorf("stop_sequences = %v", u.StopSequences)
|
||
}
|
||
// 缺省 thinking → Go 侧显式发 {enabled:false};Lua 必须一致。
|
||
if u.Thinking == nil || u.Thinking.Enabled == nil || *u.Thinking.Enabled != false {
|
||
t.Errorf("缺省 thinking 应为 {enabled:false}, 实得 %s", out)
|
||
}
|
||
}
|
||
|
||
// TestAnthropicDeserializeRejectsMissingMessages 验证 Lua 侧与 Go 侧一样,
|
||
// 对缺 messages 的请求报错(而不是产出空请求)。
|
||
func TestAnthropicDeserializeRejectsMissingMessages(t *testing.T) {
|
||
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer vm.Stop()
|
||
|
||
if _, err := vm.Transform("anthropic", "deserialize_request", `{"model":"x","max_tokens":8}`); err == nil {
|
||
t.Error("缺 messages 应报错")
|
||
}
|
||
}
|