From bd404c16f2ff28c7b21c560f816be0b3af4b21c9 Mon Sep 17 00:00:00 2001 From: Lucky <1646721+luckydududu@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:17:09 +0000 Subject: [PATCH] fix(channel): align pricing conflict detection with the pricing cache key `validateNoConflictingModels` used `toModelEntry`, which only lowercases, while `expandPricingToCache` keys the cache with `normalizeChannelPricingModelName` (lowercase + TrimSpace + `.` -> `-` for `claude-*`). Two pricing entries that the validator considers distinct therefore collapse onto the same cache key, and the one written later silently overwrites the other. Add `toPricingModelEntry`, which reuses `normalizeChannelPricingModelName`, and use it for pricing conflict detection. `toModelEntry` is left untouched for model *mapping* validation, because `expandMappingToCache` keys the mapping cache with plain `strings.ToLower` -- normalizing mappings the same way would reject configurations that the cache keeps separate. This completes the fix for #4754, which added the normalization to the lookup side only. --- backend/internal/service/channel_service.go | 20 +++++++- .../internal/service/channel_service_test.go | 48 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index e56f699a8e..cd4d2640cb 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -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 { diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index a89e5063a2..f56f61e917 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -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{