Files
ModelRouter/internal/provider/retry_policy_test.go
JianFeeeee 688dce88d1 refactor(rename)!: llmsproxy → modelrouter(module / 二进制 / 路径 / env)
产品名早已是 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/免费池的既有改动,另批提交)。
2026-10-26 22:28:08 +08:00

132 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package provider
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"modelrouter/internal/config"
"modelrouter/internal/types"
)
// 上游重试是**内核的通用机制**,判据纯数据(status + body 子串),
// 目的是让"账号级限流、等一会儿就好"这类拒绝不冒到客户端。
//
// 真实事故(2026-10-20):CodeArts 账户并发会话上限 3 个,连续第 3~4 个请求
// 回 400 TM.00001041,最后一个请求结束后约 44~60 秒才释放;流式更糟 ——
// 上游用 HTTP 200 + error 帧表达拒绝,内核只能报 `empty stream`。
// 参考实现的处理是每 10s 重试一次 chat 直到放行,本测试钉住同一语义。
func TestChatRetriesDeclaredUpstreamRejection(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
if n <= 2 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
return
}
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 4,
}
p := newTestProvider(t, s)
resp, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err != nil {
t.Fatalf("命中重试策略后应成功,实得错误: %v", err)
}
if resp.Content != "hi" {
t.Fatalf("content = %q", resp.Content)
}
if got := atomic.LoadInt32(&hits); got != 3 {
t.Fatalf("应恰好尝试 3 次(2 次失败 + 1 次成功),实得 %d", got)
}
}
// 同一状态码但 body 不匹配 ⇒ **不许重试**(400 也常用于真正的请求错误,
// 无差别重试只会把错误延迟 3 分钟才暴露给用户)。
func TestChatDoesNotRetryUnmatchedBody(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001005","error_msg":"无正常可用套餐"}`)
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 5,
}
p := newTestProvider(t, s)
_, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err == nil {
t.Fatal("不可重试的 body 必须失败")
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("body 不匹配时只能尝试 1 次,实得 %d", got)
}
}
// 流式:只重试**握手**。命中策略 → 重试直到 200 才开始派发;一旦开始流式
// 就不再重试(重复输出比报错更糟)。
func TestChatStreamRetriesHandshakeOnly(t *testing.T) {
var hits int32
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
if n == 1 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
return
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer up.Close()
s := src("codearts-like", up.URL, "openai", "m1")
s.Retry = &config.RetryPolicy{
OnStatus: []int{400},
OnBodyContains: []string{"TM.00001041"},
Delay: "1ms",
MaxAttempts: 4,
}
p := newTestProvider(t, s)
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
Model: "m1",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
})
if err != nil {
t.Fatalf("握手重试后应成功: %v", err)
}
var content string
for ck := range ch {
content += ck.Content
}
if content != "hi" {
t.Fatalf("content = %q", content)
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("应握手重试 1 次(共 2 次请求),实得 %d", got)
}
}