Files
ModelRouter/internal/lua/adapter_prelude_equivalence_test.go
JianFeeeee a7a0c6adee refactor(lua): 11 个适配器改用共享前导 —— 删掉约 20KB 逐字重复
按前导抽出三块共通实现(都是实测逐字节相同的):

| 钩子 | 原本重复 | 各 2.2KB |
|---|---|---|
| transform_stream_chunk | github/groq/kimicode/mistral | 2210B × 4 |
| transform_response     | openai/groq/kimicode/mistral/github | 2206B × 5 |
| transform_error        | deepseek/github/groq/kimicode/openai/sensenova | 292B × 6 |

适配器总行数 2409(收工前 groq 单文件 146 行 → 46 行)。

★ 两个必须记下的教训(都在本提交里踩到并修好):

1. **抽公共代码要抽最全的实现,不是最小的**。我先抽了 transform_error 的
   最小版(只认 {error:{message}}),打挂了 TestOpenAITransformErrorEnvelopes
   的 6 个子用例 —— 最小版丢掉了 HTML 错误页、裸文本、扁平 {code,message}、
   FastAPI {detail} 等形状。openai.lua 原有的 1580B 版本才是该进前导的那份。
   抽最小实现是**静默降级**:不报错,只是少认几种错误体。

2. **抽出共性时不能丢掉"非共性但重要"的细节**。openai_response 我漏了两处:
   reasoning_content 透传、以及 tool_calls 的 id 规整(safe_tool_id)。
   后者尤其危险 —— 本网关是扇出路由器,一个非法 id("bash:0")会被回放到
   每个 Claude 上游导致整条 AUTO 链失败;且**流式与非流式必须同规则**,
   否则同一请求两条路径产出不同 id(实测报错:
   流式 "bash_0_40248ca0" vs 非流式 "bash:0")。现两条路径都走
   __prelude.tool_id。

判据:TestPreludeRefactorIsByteIdentical —— 固定输入下逐字节比对
(含"arguments 必须解码成表""usage-only chunk 必须是 done:false"
"arguments 不得留原样 JSON 字符串"等反向断言),并覆盖错误体 6 种形状
与"非 OpenAI 形状必须返回 ok=false"。

另修 channels_pool_test:断言改为**关系**(关一个模型源里少一个)而非写死
数量 —— 兜底目录会随对齐进度变化(minimax 3→4),写死数量会把正常的目录
扩充变成测试失败。

回归:internal/{lua,provider,core,gateway} 全绿。
2026-10-13 22:51:50 +08:00

124 lines
4.9 KiB
Go
Raw 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 (
"strings"
"testing"
)
// 前导重构的**等价性判据**:把重复实现抽进前导后,输出必须逐字节不变。
//
// 为什么需要它:抽公共代码最常见的失败是"看起来一样、实际差一个字段"——
// 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、
// usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。
//
// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的
// transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。
func TestPreludeRefactorIsByteIdentical(t *testing.T) {
vm, _, _ := runtimePlugins(t)
for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Fatalf("读内嵌适配器 %s: %v", name, err)
}
if err := vm.LoadAdapterSource(name, string(src)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
// 非流式:工具调用 + 两种缓存命中写法同时出现
respIn := `{"choices":[{"message":{"content":"hi","tool_calls":[{"id":"c1","type":"function",` +
`"function":{"name":"f","arguments":"{\"a\":1}"}}]},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,` +
`"prompt_cache_hit_tokens":4,"prompt_cache_miss_tokens":6}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_response", respIn)
if err != nil {
t.Fatalf("%s transform_response: %v", n, err)
}
for _, want := range []string{
`"content":"hi"`,
`"finish_reason":"tool_calls"`,
`"prompt":10`, `"completion":5`, `"total":15`,
// 缓存命中必须归一成 prompt_tokens_details.cached_tokens
`"prompt_tokens_details":{"cached_tokens":4}`,
`"prompt_cache_hit_tokens":4`,
// tool_calls 的 arguments 必须**解成表**(不是原字符串)
`"arguments":{"a":1}`,
`"name":"f"`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s: 输出缺 %s\n实际: %s", n, want, out)
}
}
// arguments 绝不能被留下原样的 JSON 字符串
if strings.Contains(out, `"arguments":"{\"a\":1}"`) {
t.Errorf("%s: arguments 未被解码成表:%s", n, out)
}
}
// 流式:内容 + reasoning + tool_calls 同帧
streamIn := `{"choices":[{"delta":{"content":"x","reasoning_content":"r",` +
`"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]},` +
`"finish_reason":null}]}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", streamIn)
if err != nil {
t.Fatalf("%s stream: %v", n, err)
}
for _, want := range []string{
`"content":"x"`, `"reasoning_content":"r"`, `"done":false`,
`"tool_calls"`, `"index":0`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s stream 缺 %s\n实际: %s", n, want, out)
}
}
}
// usage-only chunk(choices 为空)**不得**被当成终结帧
usageOnly := `{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":3}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", usageOnly)
if err != nil {
t.Fatalf("%s usage-only: %v", n, err)
}
if !strings.Contains(out, `"done":false`) {
t.Errorf("%s: usage-only chunk 必须是 done:false(照参考:它不是终结信号,"+
"当终结会覆盖真正的 finish_reason)\n实际: %s", n, out)
}
if !strings.Contains(out, `"prompt":3`) {
t.Errorf("%s: usage-only chunk 的 usage 丢了:%s", n, out)
}
}
// 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身
// (这段在 6 个适配器里原本逐字节相同)
for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} {
src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua")
if err != nil {
t.Fatalf("读 %s: %v", n, err)
}
if err := vm.LoadAdapterSource(n, string(src)); err != nil {
t.Fatalf("%s: %v", n, err)
}
reason, ok, err := vm.TransformError(n, 429, `{"error":{"message":"rate limited"}}`)
if err != nil {
t.Fatalf("%s TransformError: %v", n, err)
}
if !ok || reason != "rate limited" {
t.Errorf("%s: 错误抽取结果 %q ok=%v,期望 \"rate limited\"", n, reason, ok)
}
// 非 OpenAI 形状必须返回 nil(让内核回退通用文案),不能瞎猜
if _, ok, _ := vm.TransformError(n, 500, `{"foo":"bar"}`); ok {
t.Errorf("%s: 非 OpenAI 形状应返回 ok=false", n)
}
}
// 坏 JSON 必须原样回退(不能吞成空)
if out, err := vm.Transform("openai", "transform_response", "not json"); err != nil {
t.Fatalf("bad json: %v", err)
} else if out != "not json" {
t.Errorf("坏 JSON 应原样回退,实得: %s", out)
}
}