diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 3fd6887..65ba457 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "net/http" + "regexp" "strings" "sync/atomic" "time" @@ -517,21 +518,67 @@ func upstreamErrStatus(err error) int { return http.StatusBadGateway } +// overflowMarkers 匹配上游「上下文超窗」类措辞。 +// +// 上游写法五花八门,且**不在 pi 客户端的识别列表里**。pi 靠 +// @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 判断超窗并据此触发压缩重试, +// 而 justworker 返回的是「请精简对话历史…(Context window is full…)」—— +// 与那 25 条正则一条都不匹配,于是 pi 既不压缩也不重试,只把它当成一条 +// 普通上游错误。这里把可识别的超窗措辞归一化成 pi 一定认得的标记。 +var overflowMarkers = []*regexp.Regexp{ + regexp.MustCompile(`(?i)context[ _-]?window is full`), + regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`), + regexp.MustCompile(`(?i)exceeds? the context window`), + regexp.MustCompile(`(?i)maximum context length`), + regexp.MustCompile(`(?i)reduce the length of the messages`), + regexp.MustCompile(`(?i)too many tokens`), + regexp.MustCompile(`(?i)token limit exceeded`), + regexp.MustCompile(`请精简对话历史`), + regexp.MustCompile(`上下文(长度)?超(出|限)`), + regexp.MustCompile(`对话历史过长`), +} + +// overflowCanonical 命中 pi 的 /context[_ ]length[_ ]exceeded/i。 +const overflowCanonical = "context_length_exceeded" + +// looksLikeOverflow 判断错误文本是否属于上下文超窗。 +func looksLikeOverflow(s string) bool { + for _, re := range overflowMarkers { + if re.MatchString(s) { + return true + } + } + return false +} + // clientUpstreamErr collapses upstream failure details into a short message // for the client: per-tier bodies (WAF HTML pages, quota payloads, ...) stay // in rec.Err / the stats API and the server log instead of the response. // Per-tier one-line reasons are kept (quota/cooling skips carry no error body // and are the actionable part); each is capped so HTML dumps can't leak. +// +// 超窗会被归一化成 overflowCanonical 前缀,见 overflowMarkers 的说明。 func clientUpstreamErr(err error) string { log.Printf("[gateway] upstream failure surfaced to client: %v", err) + msg := upstreamErrSummary(err) + if looksLikeOverflow(err.Error()) || looksLikeOverflow(msg) { + return fmt.Sprintf("%s: %s", overflowCanonical, msg) + } + return msg +} + +// upstreamErrSummary 把上游失败压成一行短消息。 +func upstreamErrSummary(err error) string { var ce *scheduler.ChainErr if errors.As(err, &ce) { parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped)) for _, t := range ce.Tiers { - parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 80)) + // 160 而不是 80:短诊断词("Context window is full")常落在尾部, + // 80 字节按字节截断正好会把它切掉,超窗就再也认不出来。 + parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 160)) } for _, sk := range ce.Skipped { - parts = append(parts, types.OneLine(sk, 80)) + parts = append(parts, types.OneLine(sk, 160)) } msg := strings.Join(parts, "; ") if len(msg) > 300 { diff --git a/internal/gateway/overflow_err_test.go b/internal/gateway/overflow_err_test.go new file mode 100644 index 0000000..28110fb --- /dev/null +++ b/internal/gateway/overflow_err_test.go @@ -0,0 +1,96 @@ +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) + } +}