fix(translator): prevent silent model downgrade with boundary-aware matching

- Add missing claude-sonnet-4-7/4.7 and claude-haiku-4-7/4.7 mappings;
  previously claude-sonnet-4.7 was substring-matched by the bare
  "claude-sonnet-4" key and silently downgraded to claude-sonnet-4.
- Introduce modelMapping.boundary flag and modelKeyMatches() helper.
  Bare digit-ending keys (like claude-sonnet-4) now require the next
  character to NOT be a digit, dot, or dash-digit, so future versions
  (4.8, 5.x) also pass through without silent downgrade.
- Add 8 regression tests in TestParseModelAndThinkingNoSilentDowngrade
  covering the 4.7 family, hypothetical 4.8, Bedrock-style names, and
  thinking-suffix variants.
This commit is contained in:
2026-05-11 19:16:05 +08:00
parent 3b791a6926
commit 6d1d1c68a9
2 changed files with 96 additions and 26 deletions

View File

@@ -12,33 +12,66 @@ import (
// 模型映射(有序,长 key 优先匹配,避免 "claude-sonnet-4" 误匹配 "claude-sonnet-4.5"
type modelMapping struct {
key string
value string
key string
value string
boundary bool // 仅在 key 末位为单纯版本号时启用:要求 key 后面不能再跟 .X / -X / 数字
}
var modelMapOrdered = []modelMapping{
{"claude-sonnet-4-20250514", "claude-sonnet-4"},
{"claude-sonnet-4-5", "claude-sonnet-4.5"},
{"claude-sonnet-4.5", "claude-sonnet-4.5"},
{"claude-sonnet-4-6", "claude-sonnet-4.6"},
{"claude-sonnet-4.6", "claude-sonnet-4.6"},
{"claude-opus-4-7", "claude-opus-4.7"},
{"claude-opus-4.7", "claude-opus-4.7"},
{"claude-haiku-4-5", "claude-haiku-4.5"},
{"claude-haiku-4.5", "claude-haiku-4.5"},
{"claude-opus-4-5", "claude-opus-4.5"},
{"claude-opus-4.5", "claude-opus-4.5"},
{"claude-opus-4-6", "claude-opus-4.6"},
{"claude-opus-4.6", "claude-opus-4.6"},
{"claude-sonnet-4", "claude-sonnet-4"},
{"claude-3-5-sonnet", "claude-sonnet-4.5"},
{"claude-3-opus", "claude-sonnet-4.5"},
{"claude-3-sonnet", "claude-sonnet-4"},
{"claude-3-haiku", "claude-haiku-4.5"},
{"gpt-4-turbo", "claude-sonnet-4.5"},
{"gpt-4o", "claude-sonnet-4.5"},
{"gpt-4", "claude-sonnet-4.5"},
{"gpt-3.5-turbo", "claude-sonnet-4.5"},
{key: "claude-sonnet-4-20250514", value: "claude-sonnet-4"},
{key: "claude-sonnet-4-5", value: "claude-sonnet-4.5"},
{key: "claude-sonnet-4.5", value: "claude-sonnet-4.5"},
{key: "claude-sonnet-4-6", value: "claude-sonnet-4.6"},
{key: "claude-sonnet-4.6", value: "claude-sonnet-4.6"},
{key: "claude-sonnet-4-7", value: "claude-sonnet-4.7"},
{key: "claude-sonnet-4.7", value: "claude-sonnet-4.7"},
{key: "claude-opus-4-7", value: "claude-opus-4.7"},
{key: "claude-opus-4.7", value: "claude-opus-4.7"},
{key: "claude-haiku-4-5", value: "claude-haiku-4.5"},
{key: "claude-haiku-4.5", value: "claude-haiku-4.5"},
{key: "claude-haiku-4-7", value: "claude-haiku-4.7"},
{key: "claude-haiku-4.7", value: "claude-haiku-4.7"},
{key: "claude-opus-4-5", value: "claude-opus-4.5"},
{key: "claude-opus-4.5", value: "claude-opus-4.5"},
{key: "claude-opus-4-6", value: "claude-opus-4.6"},
{key: "claude-opus-4.6", value: "claude-opus-4.6"},
{key: "claude-sonnet-4", value: "claude-sonnet-4", boundary: true},
{key: "claude-3-5-sonnet", value: "claude-sonnet-4.5"},
{key: "claude-3-opus", value: "claude-sonnet-4.5"},
{key: "claude-3-sonnet", value: "claude-sonnet-4"},
{key: "claude-3-haiku", value: "claude-haiku-4.5"},
{key: "gpt-4-turbo", value: "claude-sonnet-4.5"},
{key: "gpt-4o", value: "claude-sonnet-4.5"},
{key: "gpt-4", value: "claude-sonnet-4.5"},
{key: "gpt-3.5-turbo", value: "claude-sonnet-4.5"},
}
// modelKeyMatches 判断 input 是否匹配 mapping 的 key。
// 当 mapping.boundary=true 时,要求 key 后紧跟的字符不属于版本号延续
// (数字、点、或 "-数字"),防止 "claude-sonnet-4" 误吃 "claude-sonnet-4.7"。
func modelKeyMatches(input string, m modelMapping) bool {
idx := strings.Index(input, m.key)
if idx < 0 {
return false
}
if !m.boundary {
return true
}
end := idx + len(m.key)
if end >= len(input) {
return true
}
next := input[end]
if (next >= '0' && next <= '9') || next == '.' {
return false
}
if next == '-' && end+1 < len(input) {
n2 := input[end+1]
if n2 >= '0' && n2 <= '9' {
return false
}
}
return true
}
// Thinking 模式提示
@@ -61,9 +94,9 @@ func ParseModelAndThinking(model string, thinkingSuffix string) (string, bool) {
lower = strings.ToLower(model)
}
// 映射模型(有序匹配,长 key 优先)
// 映射模型(有序匹配,长 key 优先;带 boundary 标记的 key 要求版本号边界
for _, m := range modelMapOrdered {
if strings.Contains(lower, m.key) {
if modelKeyMatches(lower, m) {
return m.value, thinking
}
}

View File

@@ -260,3 +260,40 @@ func TestToolResultsContinuationIncludesInstructionPrefix(t *testing.T) {
t.Fatalf("expected tool result text in continuation content, got %q", content)
}
}
func TestParseModelAndThinkingNoSilentDowngrade(t *testing.T) {
cases := []struct {
input string
want string
thinking bool
}{
// 4.7 family must not silently fall back to 4
{"claude-sonnet-4.7", "claude-sonnet-4.7", false},
{"claude-sonnet-4-7", "claude-sonnet-4.7", false},
{"claude-sonnet-4.7-thinking", "claude-sonnet-4.7", true},
{"claude-opus-4.7", "claude-opus-4.7", false},
{"claude-opus-4-7", "claude-opus-4.7", false},
{"claude-opus-4.7-thinking", "claude-opus-4.7", true},
{"claude-haiku-4.7", "claude-haiku-4.7", false},
{"claude-haiku-4-7", "claude-haiku-4.7", false},
// Existing canonical names still work
{"claude-sonnet-4", "claude-sonnet-4", false},
{"claude-sonnet-4.5", "claude-sonnet-4.5", false},
{"claude-sonnet-4.5-thinking", "claude-sonnet-4.5", true},
{"claude-sonnet-4-20250514", "claude-sonnet-4", false},
// Bedrock-style still maps via Contains on dated keys
{"anthropic.claude-sonnet-4-5-20250929-v1:0", "claude-sonnet-4.5", false},
// Hypothetical future 4.8 must not silently downgrade to 4
{"claude-sonnet-4.8", "claude-sonnet-4.8", false},
}
for _, tc := range cases {
got, gotThinking := ParseModelAndThinking(tc.input, "-thinking")
if got != tc.want || gotThinking != tc.thinking {
t.Errorf("ParseModelAndThinking(%q) = (%q, %v); want (%q, %v)",
tc.input, got, gotThinking, tc.want, tc.thinking)
}
}
}