package gateway import ( "errors" "regexp" "strings" "testing" "llmsproxy/internal/scheduler" "llmsproxy/internal/types" ) // piOverflowRe 是 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 中对应的一条。 // 归一化标记必须命中它,否则改了等于没改:客户端仍不会压缩重试。 var piOverflowRe = regexp.MustCompile(`context[_ ]length[_ ]exceeded`) func TestOverflowCanonicalMatchesPiPattern(t *testing.T) { if !piOverflowRe.MatchString(overflowCanonical) { t.Fatalf("归一化标记 %q 不匹配 pi 的超窗正则,客户端仍不会压缩重试", overflowCanonical) } } // justworker 的真实措辞:与 pi 现有 25 条正则一条都不匹配, // 所以必须在网关侧归一化,否则超窗对客户端完全不可见。 func TestJustworkerOverflowIsNormalized(t *testing.T) { raw := "请精简对话历史…(Context window is full, please reduce the conversation history)…" // 前提校验:原始措辞确实不被 pi 认出(否则本用例失去意义)。 if piOverflowRe.MatchString(raw) { t.Fatalf("前提不成立:原始措辞本就匹配 pi 正则: %q", raw) } if !looksLikeOverflow(raw) { t.Fatalf("网关应识别出这是超窗: %q", raw) } err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{ Tier: 1, Source: "justworker", Model: "claude-opus-5", Err: errors.New(raw), }}} got := clientUpstreamErr(err) if !piOverflowRe.MatchString(got) { t.Fatalf("归一化后仍不匹配 pi 正则,客户端不会压缩重试: %q", got) } } // 直连路径(非 AUTO 链)也要归一化——它走的是同一个入口。 func TestDirectPathOverflowIsNormalized(t *testing.T) { got := clientUpstreamErr(errors.New("upstream 400: Context window is full")) if !piOverflowRe.MatchString(got) { t.Fatalf("直连超窗未被归一化: %q", got) } } // 非超窗错误不能被加上超窗标记,否则会把普通失败误报成需要压缩。 func TestNonOverflowNotNormalized(t *testing.T) { err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{ Tier: 1, Source: "x", Model: "y", Err: errors.New("502 Bad Gateway: upstream connect error"), }}} got := clientUpstreamErr(err) if strings.Contains(got, overflowCanonical) { t.Fatalf("普通上游失败被误标为超窗: %q", got) } if !strings.Contains(got, "502 Bad Gateway") { t.Fatalf("普通失败的诊断信息应保留: %q", got) } } // 回归:短诊断词常落在错误尾部,80 字节截断会正好把它切掉, // 超窗就永远认不出来。宽度放宽后必须保住。 func TestOverflowPhraseSurvivesTruncationWidth(t *testing.T) { prefix := strings.Repeat("x", 100) // OneLine 的长度限制作用于整条 "source/model: err",不是只算 err 本身。 full := "justworker/claude-opus-5: " + prefix + " Context window is full" // 前提:80 字节下短语确实会被切掉(这正是修复前的行为)。 if got := types.OneLine(full, 80); strings.Contains(got, "Context window is full") { t.Fatalf("前提不成立:80 字节下短语居然还在: %q", got) } // 160 字节下必须保住,否则超窗识别会被截断悄悄破坏。 if got := types.OneLine(full, 160); !strings.Contains(got, "Context window is full") { t.Fatalf("160 字节应保住尾部短诊断词: %q", got) } err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{ Tier: 1, Source: "justworker", Model: "claude-opus-5", Err: errors.New(prefix + " Context window is full"), }}} got := clientUpstreamErr(err) if !strings.Contains(strings.ToLower(got), "context window is full") { t.Fatalf("宽度 160 应保住尾部短诊断词,实际: %q", got) } if !piOverflowRe.MatchString(got) { t.Fatalf("归一化标记丢失: %q", got) } }