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) } } // piNonOverflowRe 是 @earendil-works/pi-ai 的 NON_OVERFLOW_PATTERNS。 // pi 的 isContextOverflow 先查这一组,命中则直接判为「非超窗」—— // 即使 OVERFLOW_PATTERNS 也命中,也不会压缩重试。 var piNonOverflowRe = []*regexp.Regexp{ regexp.MustCompile(`^(Throttling error|Service unavailable):`), regexp.MustCompile(`(?i)rate limit`), regexp.MustCompile(`(?i)too many requests`), } // piWouldCompact 复刻 isContextOverflow 的 Case 1 判定顺序: // 先排除 NON_OVERFLOW,再匹配 OVERFLOW。 func piWouldCompact(errorMessage string) bool { for _, p := range piNonOverflowRe { if p.MatchString(errorMessage) { return false } } return piOverflowRe.MatchString(errorMessage) } // issue 回归:AUTO 链失败消息天然把「超窗 tier」和「配额/限流 tier」拼在一起, // 超窗标记后面只要出现 rate limit / too many requests 的字样, // pi 就会把整条消息判为「非超窗」而拒绝压缩重试。 func TestOverflowMessageSurvivesRateLimitedSiblingTier(t *testing.T) { cases := []struct { name string sibling string }{ {"rate limit 措辞", "api error 429: rate limit exceeded"}, {"too many requests 措辞", "429 Too Many Requests"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := &scheduler.ChainErr{Tiers: []scheduler.TierError{ {Tier: 1, Source: "sensenova", Model: "glm-5.2", Err: errors.New(tc.sibling)}, {Tier: 3, Source: "gozen", Model: "deepseek-v4.1-flash", Err: errors.New("api error 400: This model's maximum context length is 1048576 tokens")}, }} got := clientUpstreamErr(err) if !piWouldCompact(got) { t.Fatalf("同链限流 tier 封杀了超窗识别,pi 不会压缩重试: %q", got) } // 干净:不得把限流 tier 的措辞带出去。 for _, p := range piNonOverflowRe { if p.MatchString(got) { t.Fatalf("超窗消息仍带限流措辞 %v: %q", p, got) } } // 但仍要能看出是哪个源超的窗。 if !strings.Contains(got, "gozen/deepseek-v4.1-flash") { t.Errorf("超窗源信息丢失: %q", got) } }) } } // 直连超窗同样要干净可用。 func TestDirectOverflowMessageIsClean(t *testing.T) { raw := "api error 400: Error from provider (Console Go): This model's maximum context length is 1048576 tokens. " + "However, you requested 1440050 tokens. Please reduce the length of the messages or completion." got := clientUpstreamErr(errors.New(raw)) if !piWouldCompact(got) { t.Fatalf("直连超窗未被 pi 识别: %q", got) } if !strings.Contains(got, overflowCanonical) { t.Fatalf("缺少归一化标记: %q", got) } }