mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
问题:上游返回上下文超窗时,客户端(pi)既不压缩也不重试,只看到一条 普通上游错误。链路两处叠加: 1. 措辞不在客户端识别列表里。pi 靠 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS(25 条正则)判断超窗,而 justworker 返回的是 「请精简对话历史…(Context window is full…)」——与那 25 条一条都不匹配 (最接近的 /context window exceeds limit/i 也不命中,因为措辞是 "is full" 而非 "exceeds limit")。 2. AUTO 链路的每条 tier 错误按 80 字节 OneLine 截断,而 "Context window is full" 这类短诊断词常出现在尾部,正好被按字节切掉(直连路径是 160 才保住)。 修法: - 新增 overflowMarkers 识别超窗措辞(含中文写法),命中时把客户端可见 错误归一化为 context_length_exceeded 前缀——它命中 pi 的 /context[_ ]length[_ ]exceeded/i,超窗因此可被发现并触发压缩重试。 - AUTO 链路每条 tier 错误宽度 80→160,短诊断词不再被截断。 归一化只加前缀,原始诊断信息保留,便于定位是哪一层超窗。 测试:internal/gateway/overflow_err_test.go(5 例,含「标记必须命中 pi 正则」、非超窗不得误标、80 vs 160 宽度的回归对比)。
97 lines
3.7 KiB
Go
97 lines
3.7 KiB
Go
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)
|
||
}
|
||
}
|