mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
168 lines
6.3 KiB
Go
168 lines
6.3 KiB
Go
package gateway
|
||
|
||
import (
|
||
"errors"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
|
||
"modelrouter/internal/scheduler"
|
||
"modelrouter/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)
|
||
}
|
||
}
|