378 lines
11 KiB
Go
378 lines
11 KiB
Go
package upstream
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nyoungo/dsfree2api/internal/config"
|
|
"github.com/nyoungo/dsfree2api/internal/httpx"
|
|
"github.com/nyoungo/dsfree2api/internal/turnstile"
|
|
)
|
|
|
|
const examplePath = "../../config.example.toml"
|
|
|
|
func testConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
cfg, err := config.Load(examplePath)
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
cfg.Upstream.AutoRefresh = false
|
|
cfg.Upstream.RefreshRetries = 0
|
|
return cfg
|
|
}
|
|
|
|
func TestRoutesKeepPrimaryFirstAndDedupe(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1", "socks5://fallback:1"}
|
|
|
|
c := New(cfg, nil, nil)
|
|
got := c.Routes()
|
|
want := []Route{{Name: "primary", Proxy: ""}, {Name: "fallback-1", Proxy: "socks5://fallback:1"}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("routes = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSlowPrimarySwitchesToFallbackBeforeContent(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
|
|
cfg.Proxy.SlowStartSeconds = 0.02
|
|
|
|
var calls []string
|
|
c := New(cfg, nil, nil)
|
|
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
|
|
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
|
|
calls = append(calls, route.Name)
|
|
if route.Name == "primary" {
|
|
select {
|
|
case <-time.After(5 * time.Second):
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
yield(Event{Kind: KindDelta, Value: "primary"})
|
|
return nil
|
|
}
|
|
yield(Event{Kind: KindDelta, Value: "fallback"})
|
|
return nil
|
|
}
|
|
|
|
var got []string
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
|
|
func(ev Event) error {
|
|
got = append(got, ev.Value)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("chat: %v", err)
|
|
}
|
|
if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) {
|
|
t.Errorf("calls = %v, want %v", calls, want)
|
|
}
|
|
if want := []string{"fallback"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("events = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPrimaryErrorSwitchesToFallbackBeforeContent(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
|
|
|
|
var calls []string
|
|
c := New(cfg, nil, nil)
|
|
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
|
|
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
|
|
calls = append(calls, route.Name)
|
|
if route.Name == "primary" {
|
|
return errors.New("primary failed")
|
|
}
|
|
yield(Event{Kind: KindDelta, Value: "fallback"})
|
|
return nil
|
|
}
|
|
|
|
var got []string
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
|
|
func(ev Event) error {
|
|
got = append(got, ev.Value)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("chat: %v", err)
|
|
}
|
|
if want := []string{"primary", "fallback-1"}; !reflect.DeepEqual(calls, want) {
|
|
t.Errorf("calls = %v, want %v", calls, want)
|
|
}
|
|
if want := []string{"fallback"}; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("events = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNoFallbackAfterContentStarted(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Proxy.FallbackURLs = []string{"socks5://fallback:1"}
|
|
|
|
var calls []string
|
|
c := New(cfg, nil, nil)
|
|
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
|
|
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
|
|
calls = append(calls, route.Name)
|
|
yield(Event{Kind: KindDelta, Value: "partial"})
|
|
return errors.New("stream failed")
|
|
}
|
|
|
|
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{},
|
|
func(Event) error { return nil })
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
var ue *UpstreamError
|
|
if !errors.As(err, &ue) {
|
|
t.Fatalf("error type = %T, want *UpstreamError", err)
|
|
}
|
|
if !ue.ContentStarted {
|
|
t.Error("expected ContentStarted to be set")
|
|
}
|
|
if want := []string{"primary"}; !reflect.DeepEqual(calls, want) {
|
|
t.Errorf("calls = %v, want %v", calls, want)
|
|
}
|
|
}
|
|
|
|
func TestQuotaNoticeTriggersQuotaError(t *testing.T) {
|
|
payload := map[string]any{
|
|
"error": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.",
|
|
"quota_notice": map[string]any{
|
|
"type": "quota_notice",
|
|
"message": "Dein heutiges Guthaben von 10.000 Token (V4-Pro) ist aufgebraucht.",
|
|
},
|
|
}
|
|
raw, _ := json.Marshal(payload)
|
|
_, err := translateEvent("error", []string{string(raw)})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !isQuota(err) {
|
|
t.Fatalf("want QuotaExhaustedError, got %T: %v", err, err)
|
|
}
|
|
if isSession(err) {
|
|
t.Error("quota error must not be a session error")
|
|
}
|
|
}
|
|
|
|
func TestSessionErrorTriggersSessionError(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{"error": "deepseek_ts_required"})
|
|
_, err := translateEvent("error", []string{string(raw)})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !isSession(err) {
|
|
t.Fatalf("want SessionVerificationError, got %T: %v", err, err)
|
|
}
|
|
if isQuota(err) {
|
|
t.Error("session error must not be a quota error")
|
|
}
|
|
}
|
|
|
|
func TestPlainSSEErrorDoesNotForceRefreshPath(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{"error": "temporary upstream failure"})
|
|
_, err := translateEvent("error", []string{string(raw)})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if isQuota(err) || isSession(err) {
|
|
t.Fatalf("plain error must not be quota/session, got %T", err)
|
|
}
|
|
}
|
|
|
|
func TestGermanTsRequiredPayloadIsSessionError(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"error": "Sicherheitspruefung erforderlich.",
|
|
"ts_required": true,
|
|
})
|
|
_, err := translateEvent("error", []string{string(raw)})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !isSession(err) {
|
|
t.Fatalf("want SessionVerificationError, got %T: %v", err, err)
|
|
}
|
|
}
|
|
|
|
func TestTurnstileRequiredWhileDisabledFailsFast(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Upstream.AutoRefresh = true
|
|
cfg.Upstream.RefreshRetries = 4
|
|
|
|
var calls int
|
|
c := New(cfg, nil, nil)
|
|
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
|
|
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
|
|
calls++
|
|
return newSession(`sse error: {"error":"Sicherheitspruefung erforderlich.","ts_required":true}`)
|
|
}
|
|
|
|
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil })
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "requires Turnstile verification") {
|
|
t.Errorf("error = %q, want a Turnstile hint", err.Error())
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("upstream calls = %d, want 1 (no blind refresh retries)", calls)
|
|
}
|
|
}
|
|
|
|
func TestNonceFetchFailureFailsFast(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Proxy.URL = ""
|
|
cfg.Upstream.AutoRefresh = true
|
|
cfg.Upstream.RefreshRetries = 4
|
|
|
|
var calls int
|
|
c := New(cfg, nil, nil)
|
|
c.chatOnceOverride = func(ctx context.Context, site config.Site, modelID string, model config.Model,
|
|
prompt string, route Route, info *ServeInfo, yield func(Event) error) error {
|
|
calls++
|
|
return errf("nonce fetch failed for %s: <!DOCTYPE html>", modelID)
|
|
}
|
|
|
|
err := c.Chat(context.Background(), "deepseek-v4-flash-de", "hello", &ServeInfo{}, func(Event) error { return nil })
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("upstream calls = %d, want 1 (config problems are not retried)", calls)
|
|
}
|
|
if !isConfigProblem(err) {
|
|
t.Errorf("err = %v, want isConfigProblem", err)
|
|
}
|
|
}
|
|
|
|
func TestSSEDeltaAndDoneEvents(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{"delta": "hello world"})
|
|
evs, err := translateEvent("message", []string{string(raw)})
|
|
if err != nil {
|
|
t.Fatalf("translate: %v", err)
|
|
}
|
|
if len(evs) != 1 || evs[0].Kind != KindDelta || evs[0].Value != "hello world" {
|
|
t.Fatalf("events = %+v", evs)
|
|
}
|
|
evs, err = translateEvent("message", []string{"[DONE]"})
|
|
if err != nil || len(evs) != 1 || evs[0].Kind != KindDone {
|
|
t.Fatalf("done event = %+v err=%v", evs, err)
|
|
}
|
|
}
|
|
|
|
func TestQuotaSiteCoolsDownAndPrefersSibling(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
c := New(cfg, nil, nil)
|
|
calls := map[string]int{}
|
|
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
|
|
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
|
|
calls[site.Code]++
|
|
if site.Code == "de" {
|
|
return newQuota("sse quota exhausted: test")
|
|
}
|
|
return nil
|
|
}
|
|
emit := func(Event) error { return nil }
|
|
|
|
// Round 1: primary reports quota, a sibling serves the request.
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, emit); err != nil {
|
|
t.Fatalf("first Chat: %v", err)
|
|
}
|
|
if calls["de"] != 1 {
|
|
t.Fatalf("de calls = %d, want 1", calls["de"])
|
|
}
|
|
if calls["es"]+calls["fr"] == 0 {
|
|
t.Fatal("sibling site was not attempted after primary quota error")
|
|
}
|
|
|
|
// Round 2: de sits on cooldown and must be skipped entirely.
|
|
delete(calls, "de")
|
|
delete(calls, "es")
|
|
delete(calls, "fr")
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, emit); err != nil {
|
|
t.Fatalf("second Chat: %v", err)
|
|
}
|
|
if calls["de"] != 0 {
|
|
t.Fatalf("cooled site hit again: %d calls", calls["de"])
|
|
}
|
|
if calls["es"]+calls["fr"] == 0 {
|
|
t.Fatal("sibling site not used on second round")
|
|
}
|
|
if !c.quotaCooled("de") {
|
|
t.Fatal("de should still be cooling down")
|
|
}
|
|
}
|
|
|
|
func TestCacheEmptyFailsOverToMirrorWithoutRefreshCycles(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Upstream.AutoRefresh = true
|
|
cfg.Upstream.RefreshRetries = 3
|
|
c := New(cfg, nil, nil)
|
|
calls := map[string]int{}
|
|
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
|
|
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
|
|
calls[site.Code]++
|
|
if site.Code == "de" {
|
|
return errf(`cache message http 400: {"success":false,"data":{"code":"empty_data_to_cache"}}`)
|
|
}
|
|
return nil
|
|
}
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, func(Event) error { return nil }); err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if calls["de"] != 1 {
|
|
t.Fatalf("de calls = %d, want 1 (cache rejection must fail fast, not burn refresh cycles)", calls["de"])
|
|
}
|
|
if calls["es"]+calls["fr"] == 0 {
|
|
t.Fatal("mirror was not attempted after cache rejection")
|
|
}
|
|
}
|
|
|
|
func TestQuotaRotationRecoversWithoutSolve(t *testing.T) {
|
|
cfg := testConfig(t)
|
|
cfg.Upstream.AutoRefresh = true
|
|
cfg.Upstream.RefreshRetries = 3
|
|
solver := turnstile.New(cfg.Turnstile, httpx.DefaultUserAgent)
|
|
const oldGid = "OLDGIDOLDGIDOLDGIDOLDGIDOLDG12"
|
|
if _, _, err := solver.ImportCookies("de", "cf_clearance=abc; dsgt_gid="+oldGid, ""); err != nil {
|
|
t.Fatalf("seed cookies: %v", err)
|
|
}
|
|
c := New(cfg, solver, nil)
|
|
calls := 0
|
|
c.chatOnceOverride = func(_ context.Context, site config.Site, modelID string, model config.Model,
|
|
_ string, _ Route, _ *ServeInfo, _ func(Event) error) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return newQuota("sse quota exhausted: test")
|
|
}
|
|
return nil
|
|
}
|
|
if err := c.Chat(context.Background(), "deepseek-v4-flash-de", "p", nil, func(Event) error { return nil }); err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if calls != 2 {
|
|
t.Fatalf("chat calls = %d, want 2 (quota then success)", calls)
|
|
}
|
|
gid, ok := solver.GuestID("de", "")
|
|
if !ok {
|
|
t.Fatal("guest id lost after rotation")
|
|
}
|
|
if gid == oldGid {
|
|
t.Fatal("visitor id was not rotated after the quota error")
|
|
}
|
|
if c.quotaCooled("de") {
|
|
t.Error("cooldown should be cleared once the site serves again")
|
|
}
|
|
}
|