Merge pull request #5540 from luckydududu/fix/channel-pricing-model-name-normalization

fix(channel): 定价冲突检测与定价缓存键的归一化对齐(#4754 修复残留)
This commit is contained in:
Wesley Liddick
2026-08-13 09:12:41 +08:00
committed by GitHub
2 changed files with 66 additions and 2 deletions
+18 -2
View File
@@ -962,19 +962,35 @@ func conflictsBetween(a, b modelEntry) bool {
}
}
// toModelEntry 将模型名转换为 modelEntry
// toModelEntry 将模型名转换为 modelEntry(用于模型映射的冲突检测)。
// 归一化必须与 expandMappingToCache 写缓存键的方式一致:映射缓存只做 strings.ToLower。
func toModelEntry(pattern string) modelEntry {
prefix, isWild := splitWildcardSuffix(strings.ToLower(pattern))
return modelEntry{pattern: pattern, prefix: prefix, wildcard: isWild}
}
// toPricingModelEntry 将模型名转换为 modelEntry(用于模型定价的冲突检测)。
//
// 与 toModelEntry 的区别:定价缓存的键走 normalizeChannelPricingModelName
// (额外做 TrimSpace,并把 claude-* 的 "." 换成 "-"),冲突检测必须用同一套归一化,
// 否则两个校验时看着不同、写进缓存后键相同的定价会互相静默覆盖。
func toPricingModelEntry(pattern string) modelEntry {
// 先剥通配符再归一化,与 expandPricingToCache 的处理顺序保持一致
prefix, isWild := splitWildcardSuffix(pattern)
return modelEntry{
pattern: pattern,
prefix: normalizeChannelPricingModelName(prefix),
wildcard: isWild,
}
}
// validateNoConflictingModels 检查定价列表中是否有冲突模型模式(同一平台下)。
// 冲突包括:精确重复、通配符之间的前缀包含、通配符与精确名的前缀匹配。
func validateNoConflictingModels(pricingList []ChannelModelPricing) error {
byPlatform := make(map[string][]modelEntry)
for _, p := range pricingList {
for _, model := range p.Models {
byPlatform[p.Platform] = append(byPlatform[p.Platform], toModelEntry(model))
byPlatform[p.Platform] = append(byPlatform[p.Platform], toPricingModelEntry(model))
}
}
for platform, entries := range byPlatform {
@@ -414,6 +414,44 @@ func TestValidateNoConflictingModels(t *testing.T) {
wantErr: true,
errContains: "conflict",
},
// 以下三例:冲突检测必须与 normalizeChannelPricingModelName 用同一套归一化,
// 否则校验放行、写进缓存后键相同,后写的定价会静默覆盖前一条。
{
name: "claude_dot_and_hyphen_spelling_conflict",
pricingList: []ChannelModelPricing{
{Platform: "anthropic", Models: []string{"claude-sonnet-4.5"}},
{Platform: "anthropic", Models: []string{"claude-sonnet-4-5"}},
},
wantErr: true,
errContains: "conflict",
},
{
name: "claude_dot_and_hyphen_spelling_conflict_wildcard",
pricingList: []ChannelModelPricing{
{Platform: "anthropic", Models: []string{"claude-sonnet-4.5*"}},
{Platform: "anthropic", Models: []string{"claude-sonnet-4-5-x"}},
},
wantErr: true,
errContains: "conflict",
},
{
name: "surrounding_whitespace_conflict",
pricingList: []ChannelModelPricing{
{Platform: "openai", Models: []string{"gpt-5.6"}},
{Platform: "openai", Models: []string{" gpt-5.6 "}},
},
wantErr: true,
errContains: "conflict",
},
{
// 只有 claude-* 前缀才做 "." → "-",别把其它平台也一起归一化了
name: "non_claude_dot_spelling_is_not_normalized",
pricingList: []ChannelModelPricing{
{Platform: "openai", Models: []string{"gpt-5.6"}},
{Platform: "openai", Models: []string{"gpt-5-6"}},
},
wantErr: false,
},
}
for _, tt := range tests {
@@ -469,6 +507,16 @@ func TestValidateNoConflictingMappings(t *testing.T) {
wantErr: true,
errContains: "conflict",
},
{
// 映射缓存(expandMappingToCache)只做 strings.ToLower,不做定价那套
// "." → "-",所以这两个源模式在缓存里是两个不同的键、并不冲突。
// 这条用来卡住:定价侧的归一化修复不能顺手套到映射侧,否则会误报冲突。
name: "mapping keeps dot and hyphen spelling separate",
mapping: map[string]map[string]string{
"anthropic": {"claude-sonnet-4.5": "a", "claude-sonnet-4-5": "b"},
},
wantErr: false,
},
{
name: "wildcard vs exact conflict",
mapping: map[string]map[string]string{