Files
ModelRouter/internal/lua/ser_check_test.go
JianFeeeee e4111337cd 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 ./... 全绿。
2026-10-11 09:51:27 +08:00

54 lines
1.5 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)
}
}
}