Files
ModelRouter/internal/lua/ser_check_test.go
JianFeeeee 3c59c33c58 feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出
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 台账更新。
2026-10-12 16:32:30 +08:00

149 lines
4.9 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 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 应报错")
}
}