- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误 - Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐 - TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配 - metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理 - GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示, 不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚 - set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、 限流或求解开关按零值清掉 - 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限 - main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部 挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray - proxypool / turnstile / upstream / openai / config / httpx / logbuf 等 逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
100 lines
2.6 KiB
Go
100 lines
2.6 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nyoungo/dsfree2api/internal/config"
|
|
"github.com/nyoungo/dsfree2api/internal/logbuf"
|
|
"github.com/nyoungo/dsfree2api/internal/metrics"
|
|
"github.com/nyoungo/dsfree2api/internal/turnstile"
|
|
"github.com/nyoungo/dsfree2api/internal/upstream"
|
|
)
|
|
|
|
func newTestServerWithConfig(t *testing.T, cfg *config.Config) *Server {
|
|
t.Helper()
|
|
met := metrics.New(t.TempDir())
|
|
met.Start()
|
|
t.Cleanup(met.Close)
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
ts := turnstile.New(cfg.Turnstile, cfg.Upstream.UserAgent)
|
|
up := upstream.New(cfg, ts, log, nil)
|
|
return New(cfg, up, met, logbuf.New(10), ts, log)
|
|
}
|
|
|
|
// 回归:一次流式请求只能计一次 in_flight。handleChat 已经计过,streamChat
|
|
// 里又计一次,控制台的并发数会按 2 倍显示。
|
|
func TestStreamingRequestCountsInFlightOnce(t *testing.T) {
|
|
// 假站点:每个请求拖 300ms,好让测试在请求进行中采样并发数
|
|
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(300 * time.Millisecond)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write([]byte("<html><body>nope</body></html>"))
|
|
}))
|
|
t.Cleanup(hang.Close)
|
|
|
|
cfg, err := config.Load(examplePath)
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
cfg.Turnstile.Enabled = false
|
|
cfg.Upstream.AutoRefresh = false
|
|
cfg.Upstream.RefreshRetries = 0
|
|
cfg.Upstream.CrossSiteFailover = false
|
|
// 所有站点都指向假站点,避免 failover 打到公网
|
|
for _, st := range cfg.Sites {
|
|
st.BaseURL = hang.URL
|
|
st.AJAXURL = hang.URL + "/wp-admin/admin-ajax.php"
|
|
}
|
|
var modelID string
|
|
for id, m := range cfg.Models {
|
|
if m.Enabled {
|
|
modelID = id
|
|
break
|
|
}
|
|
}
|
|
if modelID == "" {
|
|
t.Fatalf("example config 里没有可用模型")
|
|
}
|
|
|
|
s := newTestServerWithConfig(t, cfg)
|
|
|
|
body := `{"model":"` + modelID + `","messages":[{"role":"user","content":"hi"}],"stream":true}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
s.handleChat(w, req)
|
|
}()
|
|
|
|
var max int64
|
|
ticker := time.NewTicker(time.Millisecond)
|
|
defer ticker.Stop()
|
|
loop:
|
|
for {
|
|
if v := s.met.InFlight(); v > max {
|
|
max = v
|
|
}
|
|
select {
|
|
case <-done:
|
|
break loop
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
<-done
|
|
|
|
if max > 1 {
|
|
t.Fatalf("一次流式请求的 in_flight 峰值 = %d, want 1", max)
|
|
}
|
|
if max == 0 {
|
|
t.Fatalf("请求期间 in_flight 一直是 0,测试没抓到窗口")
|
|
}
|
|
}
|