merge: 合并 main 分支到 test,解决 config 和 modelWhitelist 冲突
- config.go: 保留 Sora 配置,合入 SubscriptionCache 配置 - useModelWhitelist.ts: 同时保留 soraModels 和 antigravityModels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,12 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
@@ -347,10 +350,18 @@ func parseTempUnschedInt(value any) int {
|
||||
|
||||
func (a *Account) GetModelMapping() map[string]string {
|
||||
if a.Credentials == nil {
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
return nil
|
||||
}
|
||||
raw, ok := a.Credentials["model_mapping"]
|
||||
if !ok || raw == nil {
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
@@ -364,27 +375,46 @@ func (a *Account) GetModelMapping() map[string]string {
|
||||
return result
|
||||
}
|
||||
}
|
||||
// Antigravity 平台使用默认映射
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
|
||||
// 如果未配置 mapping,返回 true(允许所有模型)
|
||||
func (a *Account) IsModelSupported(requestedModel string) bool {
|
||||
mapping := a.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
return true // 无映射 = 允许所有
|
||||
}
|
||||
// 精确匹配
|
||||
if _, exists := mapping[requestedModel]; exists {
|
||||
return true
|
||||
}
|
||||
_, exists := mapping[requestedModel]
|
||||
return exists
|
||||
// 通配符匹配
|
||||
for pattern := range mapping {
|
||||
if matchWildcard(pattern, requestedModel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
|
||||
// 如果未配置 mapping,返回原始模型名
|
||||
func (a *Account) GetMappedModel(requestedModel string) string {
|
||||
mapping := a.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
return requestedModel
|
||||
}
|
||||
// 精确匹配优先
|
||||
if mappedModel, exists := mapping[requestedModel]; exists {
|
||||
return mappedModel
|
||||
}
|
||||
return requestedModel
|
||||
// 通配符匹配(最长优先)
|
||||
return matchWildcardMapping(mapping, requestedModel)
|
||||
}
|
||||
|
||||
func (a *Account) GetBaseURL() string {
|
||||
@@ -426,6 +456,53 @@ func (a *Account) GetClaudeUserID() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchAntigravityWildcard 通配符匹配(仅支持末尾 *)
|
||||
// 用于 model_mapping 的通配符匹配
|
||||
func matchAntigravityWildcard(pattern, str string) bool {
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return strings.HasPrefix(str, prefix)
|
||||
}
|
||||
return pattern == str
|
||||
}
|
||||
|
||||
// matchWildcard 通用通配符匹配(仅支持末尾 *)
|
||||
// 复用 Antigravity 的通配符逻辑,供其他平台使用
|
||||
func matchWildcard(pattern, str string) bool {
|
||||
return matchAntigravityWildcard(pattern, str)
|
||||
}
|
||||
|
||||
// matchWildcardMapping 通配符映射匹配(最长优先)
|
||||
// 如果没有匹配,返回原始字符串
|
||||
func matchWildcardMapping(mapping map[string]string, requestedModel string) string {
|
||||
// 收集所有匹配的 pattern,按长度降序排序(最长优先)
|
||||
type patternMatch struct {
|
||||
pattern string
|
||||
target string
|
||||
}
|
||||
var matches []patternMatch
|
||||
|
||||
for pattern, target := range mapping {
|
||||
if matchWildcard(pattern, requestedModel) {
|
||||
matches = append(matches, patternMatch{pattern, target})
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return requestedModel // 无匹配,返回原始模型名
|
||||
}
|
||||
|
||||
// 按 pattern 长度降序排序
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if len(matches[i].pattern) != len(matches[j].pattern) {
|
||||
return len(matches[i].pattern) > len(matches[j].pattern)
|
||||
}
|
||||
return matches[i].pattern < matches[j].pattern
|
||||
})
|
||||
|
||||
return matches[0].target
|
||||
}
|
||||
|
||||
func (a *Account) IsCustomErrorCodesEnabled() bool {
|
||||
if a.Type != AccountTypeAPIKey || a.Credentials == nil {
|
||||
return false
|
||||
|
||||
@@ -36,8 +36,8 @@ type UsageLogRepository interface {
|
||||
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, stream *bool, billingType *int8) ([]usagestats.ModelStat, error)
|
||||
GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.APIKeyUsageTrendPoint, error)
|
||||
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
|
||||
GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error)
|
||||
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
||||
GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error)
|
||||
GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error)
|
||||
|
||||
// User dashboard stats
|
||||
GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchWildcard(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
str string
|
||||
expected bool
|
||||
}{
|
||||
// 精确匹配
|
||||
{"exact match", "claude-sonnet-4-5", "claude-sonnet-4-5", true},
|
||||
{"exact mismatch", "claude-sonnet-4-5", "claude-opus-4-5", false},
|
||||
|
||||
// 通配符匹配
|
||||
{"wildcard prefix match", "claude-*", "claude-sonnet-4-5", true},
|
||||
{"wildcard prefix match 2", "claude-*", "claude-opus-4-5-thinking", true},
|
||||
{"wildcard prefix mismatch", "claude-*", "gemini-3-flash", false},
|
||||
{"wildcard partial match", "gemini-3*", "gemini-3-flash", true},
|
||||
{"wildcard partial match 2", "gemini-3*", "gemini-3-pro-image", true},
|
||||
{"wildcard partial mismatch", "gemini-3*", "gemini-2.5-flash", false},
|
||||
|
||||
// 边界情况
|
||||
{"empty pattern exact", "", "", true},
|
||||
{"empty pattern mismatch", "", "claude", false},
|
||||
{"single star", "*", "anything", true},
|
||||
{"star at end only", "abc*", "abcdef", true},
|
||||
{"star at end empty suffix", "abc*", "abc", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := matchWildcard(tt.pattern, tt.str)
|
||||
if result != tt.expected {
|
||||
t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.str, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchWildcardMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mapping map[string]string
|
||||
requestedModel string
|
||||
expected string
|
||||
}{
|
||||
// 精确匹配优先于通配符
|
||||
{
|
||||
name: "exact match takes precedence",
|
||||
mapping: map[string]string{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-exact",
|
||||
"claude-*": "claude-default",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5-exact",
|
||||
},
|
||||
|
||||
// 最长通配符优先
|
||||
{
|
||||
name: "longer wildcard takes precedence",
|
||||
mapping: map[string]string{
|
||||
"claude-*": "claude-default",
|
||||
"claude-sonnet-*": "claude-sonnet-default",
|
||||
"claude-sonnet-4*": "claude-sonnet-4-series",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-series",
|
||||
},
|
||||
|
||||
// 单个通配符
|
||||
{
|
||||
name: "single wildcard",
|
||||
mapping: map[string]string{
|
||||
"claude-*": "claude-mapped",
|
||||
},
|
||||
requestedModel: "claude-opus-4-5",
|
||||
expected: "claude-mapped",
|
||||
},
|
||||
|
||||
// 无匹配返回原始模型
|
||||
{
|
||||
name: "no match returns original",
|
||||
mapping: map[string]string{
|
||||
"claude-*": "claude-mapped",
|
||||
},
|
||||
requestedModel: "gemini-3-flash",
|
||||
expected: "gemini-3-flash",
|
||||
},
|
||||
|
||||
// 空映射返回原始模型
|
||||
{
|
||||
name: "empty mapping returns original",
|
||||
mapping: map[string]string{},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
|
||||
// Gemini 模型映射
|
||||
{
|
||||
name: "gemini wildcard mapping",
|
||||
mapping: map[string]string{
|
||||
"gemini-3*": "gemini-3-pro-high",
|
||||
"gemini-2.5*": "gemini-2.5-flash",
|
||||
},
|
||||
requestedModel: "gemini-3-flash-preview",
|
||||
expected: "gemini-3-pro-high",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := matchWildcardMapping(tt.mapping, tt.requestedModel)
|
||||
if result != tt.expected {
|
||||
t.Errorf("matchWildcardMapping(%v, %q) = %q, want %q", tt.mapping, tt.requestedModel, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountIsModelSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
requestedModel string
|
||||
expected bool
|
||||
}{
|
||||
// 无映射 = 允许所有
|
||||
{
|
||||
name: "no mapping allows all",
|
||||
credentials: nil,
|
||||
requestedModel: "any-model",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty mapping allows all",
|
||||
credentials: map[string]any{},
|
||||
requestedModel: "any-model",
|
||||
expected: true,
|
||||
},
|
||||
|
||||
// 精确匹配
|
||||
{
|
||||
name: "exact match supported",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": "target-model",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact match not supported",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": "target-model",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-opus-4-5",
|
||||
expected: false,
|
||||
},
|
||||
|
||||
// 通配符匹配
|
||||
{
|
||||
name: "wildcard match supported",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-*": "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-opus-4-5-thinking",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard match not supported",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-*": "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
requestedModel: "gemini-3-flash",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: tt.credentials,
|
||||
}
|
||||
result := account.IsModelSupported(tt.requestedModel)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsModelSupported(%q) = %v, want %v", tt.requestedModel, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountGetMappedModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
requestedModel string
|
||||
expected string
|
||||
}{
|
||||
// 无映射 = 返回原始模型
|
||||
{
|
||||
name: "no mapping returns original",
|
||||
credentials: nil,
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
|
||||
// 精确匹配
|
||||
{
|
||||
name: "exact match",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": "target-model",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "target-model",
|
||||
},
|
||||
|
||||
// 通配符匹配(最长优先)
|
||||
{
|
||||
name: "wildcard longest match",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-*": "claude-default",
|
||||
"claude-sonnet-*": "claude-sonnet-mapped",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-mapped",
|
||||
},
|
||||
|
||||
// 无匹配返回原始模型
|
||||
{
|
||||
name: "no match returns original",
|
||||
credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"gemini-*": "gemini-mapped",
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: tt.credentials,
|
||||
}
|
||||
result := account.GetMappedModel(tt.requestedModel)
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetMappedModel(%q) = %q, want %q", tt.requestedModel, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ type AdminService interface {
|
||||
GetAllProxies(ctx context.Context) ([]Proxy, error)
|
||||
GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error)
|
||||
GetProxy(ctx context.Context, id int64) (*Proxy, error)
|
||||
GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error)
|
||||
CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error)
|
||||
UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error)
|
||||
DeleteProxy(ctx context.Context, id int64) error
|
||||
@@ -179,6 +180,8 @@ type CreateAccountInput struct {
|
||||
GroupIDs []int64
|
||||
ExpiresAt *int64
|
||||
AutoPauseOnExpired *bool
|
||||
// SkipDefaultGroupBind prevents auto-binding to platform default group when GroupIDs is empty.
|
||||
SkipDefaultGroupBind bool
|
||||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||||
// This should only be set when the caller has explicitly confirmed the risk.
|
||||
SkipMixedChannelCheck bool
|
||||
@@ -1076,7 +1079,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
||||
// 绑定分组
|
||||
groupIDs := input.GroupIDs
|
||||
// 如果没有指定分组,自动绑定对应平台的默认分组
|
||||
if len(groupIDs) == 0 {
|
||||
if len(groupIDs) == 0 && !input.SkipDefaultGroupBind {
|
||||
defaultGroupName := input.Platform + "-default"
|
||||
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
||||
if err == nil {
|
||||
@@ -1444,6 +1447,10 @@ func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, erro
|
||||
return s.proxyRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
return s.proxyRepo.ListByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) {
|
||||
proxy := &Proxy{
|
||||
Name: input.Name,
|
||||
|
||||
@@ -187,6 +187,10 @@ func (s *proxyRepoStub) GetByID(ctx context.Context, id int64) (*Proxy, error) {
|
||||
panic("unexpected GetByID call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
panic("unexpected ListByIDs call")
|
||||
}
|
||||
|
||||
func (s *proxyRepoStub) Update(ctx context.Context, proxy *Proxy) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -113,7 +115,7 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "claude-opus-4-5",
|
||||
"model": "claude-opus-4-6",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": "hi"},
|
||||
},
|
||||
@@ -149,7 +151,7 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
result, err := svc.Forward(context.Background(), c, account, body, false)
|
||||
require.Nil(t, result)
|
||||
|
||||
var promptErr *PromptTooLongError
|
||||
@@ -166,27 +168,261 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
|
||||
require.Equal(t, "prompt_too_long", events[0].Kind)
|
||||
}
|
||||
|
||||
func TestAntigravityMaxRetriesForModel_AfterSwitch(t *testing.T) {
|
||||
t.Setenv(antigravityMaxRetriesEnv, "4")
|
||||
t.Setenv(antigravityMaxRetriesAfterSwitchEnv, "7")
|
||||
t.Setenv(antigravityMaxRetriesClaudeEnv, "")
|
||||
t.Setenv(antigravityMaxRetriesGeminiTextEnv, "")
|
||||
t.Setenv(antigravityMaxRetriesGeminiImageEnv, "")
|
||||
// TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover
|
||||
// 验证:当账号存在模型限流且剩余时间 >= antigravityRateLimitThreshold 时,
|
||||
// Forward 方法应返回 UpstreamFailoverError,触发 Handler 切换账号
|
||||
func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
got := antigravityMaxRetriesForModel("claude-sonnet-4-5", false)
|
||||
require.Equal(t, 4, got)
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "claude-opus-4-6",
|
||||
"messages": []map[string]any{
|
||||
{"role": "user", "content": "hi"},
|
||||
},
|
||||
"max_tokens": 1,
|
||||
"stream": false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got = antigravityMaxRetriesForModel("claude-sonnet-4-5", true)
|
||||
require.Equal(t, 7, got)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
// 不需要真正调用上游,因为预检查会直接返回切换信号
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
|
||||
}
|
||||
|
||||
// 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
|
||||
futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "acc-rate-limited",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-opus-4-6-thinking": map[string]any{
|
||||
"rate_limit_reset_at": futureResetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body, false)
|
||||
require.Nil(t, result, "Forward should not return result when model rate limited")
|
||||
require.NotNil(t, err, "Forward should return error")
|
||||
|
||||
// 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
|
||||
// 非粘性会话请求,ForceCacheBilling 应为 false
|
||||
require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
|
||||
}
|
||||
|
||||
func TestAntigravityMaxRetriesForModel_AfterSwitchFallback(t *testing.T) {
|
||||
t.Setenv(antigravityMaxRetriesEnv, "5")
|
||||
t.Setenv(antigravityMaxRetriesAfterSwitchEnv, "")
|
||||
t.Setenv(antigravityMaxRetriesClaudeEnv, "")
|
||||
t.Setenv(antigravityMaxRetriesGeminiTextEnv, "")
|
||||
t.Setenv(antigravityMaxRetriesGeminiImageEnv, "")
|
||||
// TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover
|
||||
// 验证:ForwardGemini 方法同样能正确将 AntigravityAccountSwitchError 转换为 UpstreamFailoverError
|
||||
func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
got := antigravityMaxRetriesForModel("gemini-2.5-flash", true)
|
||||
require.Equal(t, 5, got)
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hi"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
// 不需要真正调用上游,因为预检查会直接返回切换信号
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
|
||||
}
|
||||
|
||||
// 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
|
||||
futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
|
||||
account := &Account{
|
||||
ID: 2,
|
||||
Name: "acc-gemini-rate-limited",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-2.5-flash": map[string]any{
|
||||
"rate_limit_reset_at": futureResetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, false)
|
||||
require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
|
||||
require.NotNil(t, err, "ForwardGemini should return error")
|
||||
|
||||
// 核心验证:错误应该是 UpstreamFailoverError,而不是普通 502 错误
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
|
||||
// 非粘性会话请求,ForceCacheBilling 应为 false
|
||||
require.False(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be false for non-sticky session")
|
||||
}
|
||||
|
||||
// TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling
|
||||
// 验证:粘性会话切换时,UpstreamFailoverError.ForceCacheBilling 应为 true
|
||||
func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": "claude-opus-4-6",
|
||||
"messages": []map[string]string{{"role": "user", "content": "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
|
||||
}
|
||||
|
||||
// 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
|
||||
futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
|
||||
account := &Account{
|
||||
ID: 3,
|
||||
Name: "acc-sticky-rate-limited",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-opus-4-6-thinking": map[string]any{
|
||||
"rate_limit_reset_at": futureResetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 传入 isStickySession = true
|
||||
result, err := svc.Forward(context.Background(), c, account, body, true)
|
||||
require.Nil(t, result, "Forward should not return result when model rate limited")
|
||||
require.NotNil(t, err, "Forward should return error")
|
||||
|
||||
// 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
|
||||
}
|
||||
|
||||
// TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling
|
||||
// 验证:ForwardGemini 粘性会话切换时,UpstreamFailoverError.ForceCacheBilling 应为 true
|
||||
func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hi"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(body))
|
||||
c.Request = req
|
||||
|
||||
svc := &AntigravityGatewayService{
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: &httpUpstreamStub{resp: nil, err: nil},
|
||||
}
|
||||
|
||||
// 设置模型限流:剩余时间 30 秒(> antigravityRateLimitThreshold 7s)
|
||||
futureResetAt := time.Now().Add(30 * time.Second).Format(time.RFC3339)
|
||||
account := &Account{
|
||||
ID: 4,
|
||||
Name: "acc-gemini-sticky-rate-limited",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-2.5-flash": map[string]any{
|
||||
"rate_limit_reset_at": futureResetAt,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 传入 isStickySession = true
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, body, true)
|
||||
require.Nil(t, result, "ForwardGemini should not return result when model rate limited")
|
||||
require.NotNil(t, err, "ForwardGemini should return error")
|
||||
|
||||
// 核心验证:粘性会话切换时,ForceCacheBilling 应为 true
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr, "error should be UpstreamFailoverError to trigger account switch")
|
||||
require.Equal(t, http.StatusServiceUnavailable, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.ForceCacheBilling, "ForceCacheBilling should be true for sticky session switch")
|
||||
}
|
||||
|
||||
func TestAntigravityStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
writer := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(writer)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
_, _ = pw.Write([]byte("data: {\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"cache_read_input_tokens\":3,\"cache_creation_input_tokens\":4}}\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"usage\":{\"output_tokens\":5}}\n"))
|
||||
}()
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
start := time.Now().Add(-10 * time.Millisecond)
|
||||
usage, firstTokenMs := svc.streamUpstreamResponse(c, resp, start)
|
||||
_ = pr.Close()
|
||||
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 1, usage.InputTokens)
|
||||
// 第二次事件覆盖 output_tokens
|
||||
require.Equal(t, 5, usage.OutputTokens)
|
||||
require.Equal(t, 3, usage.CacheReadInputTokens)
|
||||
require.Equal(t, 4, usage.CacheCreationInputTokens)
|
||||
|
||||
if firstTokenMs == nil {
|
||||
t.Fatalf("expected firstTokenMs to be set")
|
||||
}
|
||||
// 确保有透传输出
|
||||
require.True(t, strings.Contains(writer.Body.String(), "data:"))
|
||||
}
|
||||
|
||||
@@ -8,53 +8,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsAntigravityModelSupported(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
expected bool
|
||||
}{
|
||||
// 直接支持的模型
|
||||
{"直接支持 - claude-sonnet-4-5", "claude-sonnet-4-5", true},
|
||||
{"直接支持 - claude-opus-4-5-thinking", "claude-opus-4-5-thinking", true},
|
||||
{"直接支持 - claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking", true},
|
||||
{"直接支持 - gemini-2.5-flash", "gemini-2.5-flash", true},
|
||||
{"直接支持 - gemini-2.5-flash-lite", "gemini-2.5-flash-lite", true},
|
||||
{"直接支持 - gemini-3-pro-high", "gemini-3-pro-high", true},
|
||||
|
||||
// 可映射的模型
|
||||
{"可映射 - claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022", true},
|
||||
{"可映射 - claude-3-5-sonnet-20240620", "claude-3-5-sonnet-20240620", true},
|
||||
{"可映射 - claude-opus-4", "claude-opus-4", true},
|
||||
{"可映射 - claude-haiku-4", "claude-haiku-4", true},
|
||||
{"可映射 - claude-3-haiku-20240307", "claude-3-haiku-20240307", true},
|
||||
|
||||
// Gemini 前缀透传
|
||||
{"Gemini前缀 - gemini-2.5-pro", "gemini-2.5-pro", true},
|
||||
{"Gemini前缀 - gemini-unknown-model", "gemini-unknown-model", true},
|
||||
{"Gemini前缀 - gemini-future-version", "gemini-future-version", true},
|
||||
|
||||
// Claude 前缀兜底
|
||||
{"Claude前缀 - claude-unknown-model", "claude-unknown-model", true},
|
||||
{"Claude前缀 - claude-3-opus-20240229", "claude-3-opus-20240229", true},
|
||||
{"Claude前缀 - claude-future-version", "claude-future-version", true},
|
||||
|
||||
// 不支持的模型
|
||||
{"不支持 - gpt-4", "gpt-4", false},
|
||||
{"不支持 - gpt-4o", "gpt-4o", false},
|
||||
{"不支持 - llama-3", "llama-3", false},
|
||||
{"不支持 - mistral-7b", "mistral-7b", false},
|
||||
{"不支持 - 空字符串", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsAntigravityModelSupported(tt.model)
|
||||
require.Equal(t, tt.expected, got, "model: %s", tt.model)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
svc := &AntigravityGatewayService{}
|
||||
|
||||
@@ -64,7 +17,7 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
accountMapping map[string]string
|
||||
expected string
|
||||
}{
|
||||
// 1. 账户级映射优先(注意:model_mapping 在 credentials 中存储为 map[string]any)
|
||||
// 1. 账户级映射优先
|
||||
{
|
||||
name: "账户映射优先",
|
||||
requestedModel: "claude-3-5-sonnet-20241022",
|
||||
@@ -72,120 +25,124 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) {
|
||||
expected: "custom-model",
|
||||
},
|
||||
{
|
||||
name: "账户映射覆盖系统映射",
|
||||
name: "账户映射 - 可覆盖默认映射的模型",
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
accountMapping: map[string]string{"claude-sonnet-4-5": "my-custom-sonnet"},
|
||||
expected: "my-custom-sonnet",
|
||||
},
|
||||
{
|
||||
name: "账户映射 - 可覆盖未知模型",
|
||||
requestedModel: "claude-opus-4",
|
||||
accountMapping: map[string]string{"claude-opus-4": "my-opus"},
|
||||
expected: "my-opus",
|
||||
},
|
||||
|
||||
// 2. 系统默认映射
|
||||
// 2. 默认映射(DefaultAntigravityModelMapping)
|
||||
{
|
||||
name: "系统映射 - claude-3-5-sonnet-20241022",
|
||||
requestedModel: "claude-3-5-sonnet-20241022",
|
||||
name: "默认映射 - claude-opus-4-6 → claude-opus-4-6-thinking",
|
||||
requestedModel: "claude-opus-4-6",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-3-5-sonnet-20240620",
|
||||
requestedModel: "claude-3-5-sonnet-20240620",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-opus-4",
|
||||
requestedModel: "claude-opus-4",
|
||||
accountMapping: nil,
|
||||
expected: "claude-opus-4-5-thinking",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-opus-4-5-20251101",
|
||||
name: "默认映射 - claude-opus-4-5-20251101 → claude-opus-4-6-thinking",
|
||||
requestedModel: "claude-opus-4-5-20251101",
|
||||
accountMapping: nil,
|
||||
expected: "claude-opus-4-5-thinking",
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-haiku-4 → claude-sonnet-4-5",
|
||||
requestedModel: "claude-haiku-4",
|
||||
name: "默认映射 - claude-opus-4-5-thinking → claude-opus-4-6-thinking",
|
||||
requestedModel: "claude-opus-4-5-thinking",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-haiku-4-5 → claude-sonnet-4-5",
|
||||
name: "默认映射 - claude-haiku-4-5 → claude-sonnet-4-5",
|
||||
requestedModel: "claude-haiku-4-5",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-3-haiku-20240307 → claude-sonnet-4-5",
|
||||
requestedModel: "claude-3-haiku-20240307",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-haiku-4-5-20251001 → claude-sonnet-4-5",
|
||||
name: "默认映射 - claude-haiku-4-5-20251001 → claude-sonnet-4-5",
|
||||
requestedModel: "claude-haiku-4-5-20251001",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "系统映射 - claude-sonnet-4-5-20250929",
|
||||
name: "默认映射 - claude-sonnet-4-5-20250929 → claude-sonnet-4-5",
|
||||
requestedModel: "claude-sonnet-4-5-20250929",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
|
||||
// 3. Gemini 2.5 → 3 映射
|
||||
// 3. 默认映射中的透传(映射到自己)
|
||||
{
|
||||
name: "Gemini映射 - gemini-2.5-flash → gemini-3-flash",
|
||||
requestedModel: "gemini-2.5-flash",
|
||||
accountMapping: nil,
|
||||
expected: "gemini-3-flash",
|
||||
},
|
||||
{
|
||||
name: "Gemini映射 - gemini-2.5-pro → gemini-3-pro-high",
|
||||
requestedModel: "gemini-2.5-pro",
|
||||
accountMapping: nil,
|
||||
expected: "gemini-3-pro-high",
|
||||
},
|
||||
{
|
||||
name: "Gemini透传 - gemini-future-model",
|
||||
requestedModel: "gemini-future-model",
|
||||
accountMapping: nil,
|
||||
expected: "gemini-future-model",
|
||||
},
|
||||
|
||||
// 4. 直接支持的模型
|
||||
{
|
||||
name: "直接支持 - claude-sonnet-4-5",
|
||||
name: "默认映射透传 - claude-sonnet-4-5",
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "直接支持 - claude-opus-4-5-thinking",
|
||||
requestedModel: "claude-opus-4-5-thinking",
|
||||
name: "默认映射透传 - claude-opus-4-6-thinking",
|
||||
requestedModel: "claude-opus-4-6-thinking",
|
||||
accountMapping: nil,
|
||||
expected: "claude-opus-4-5-thinking",
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{
|
||||
name: "直接支持 - claude-sonnet-4-5-thinking",
|
||||
name: "默认映射透传 - claude-sonnet-4-5-thinking",
|
||||
requestedModel: "claude-sonnet-4-5-thinking",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
|
||||
// 5. 默认值 fallback(未知 claude 模型)
|
||||
{
|
||||
name: "默认值 - claude-unknown",
|
||||
requestedModel: "claude-unknown",
|
||||
name: "默认映射透传 - gemini-2.5-flash",
|
||||
requestedModel: "gemini-2.5-flash",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
expected: "gemini-2.5-flash",
|
||||
},
|
||||
{
|
||||
name: "默认值 - claude-3-opus-20240229",
|
||||
name: "默认映射透传 - gemini-2.5-pro",
|
||||
requestedModel: "gemini-2.5-pro",
|
||||
accountMapping: nil,
|
||||
expected: "gemini-2.5-pro",
|
||||
},
|
||||
{
|
||||
name: "默认映射透传 - gemini-3-flash",
|
||||
requestedModel: "gemini-3-flash",
|
||||
accountMapping: nil,
|
||||
expected: "gemini-3-flash",
|
||||
},
|
||||
|
||||
// 4. 未在默认映射中的模型返回空字符串(不支持)
|
||||
{
|
||||
name: "未知模型 - claude-unknown 返回空",
|
||||
requestedModel: "claude-unknown",
|
||||
accountMapping: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "未知模型 - claude-3-5-sonnet-20241022 返回空(未在默认映射)",
|
||||
requestedModel: "claude-3-5-sonnet-20241022",
|
||||
accountMapping: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "未知模型 - claude-3-opus-20240229 返回空",
|
||||
requestedModel: "claude-3-opus-20240229",
|
||||
accountMapping: nil,
|
||||
expected: "claude-sonnet-4-5",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "未知模型 - claude-opus-4 返回空",
|
||||
requestedModel: "claude-opus-4",
|
||||
accountMapping: nil,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "未知模型 - gemini-future-model 返回空",
|
||||
requestedModel: "gemini-future-model",
|
||||
accountMapping: nil,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -219,12 +176,10 @@ func TestAntigravityGatewayService_GetMappedModel_EdgeCases(t *testing.T) {
|
||||
requestedModel string
|
||||
expected string
|
||||
}{
|
||||
// 空字符串回退到默认值
|
||||
{"空字符串", "", "claude-sonnet-4-5"},
|
||||
|
||||
// 非 claude/gemini 前缀回退到默认值
|
||||
{"非claude/gemini前缀 - gpt", "gpt-4", "claude-sonnet-4-5"},
|
||||
{"非claude/gemini前缀 - llama", "llama-3", "claude-sonnet-4-5"},
|
||||
// 空字符串和非 claude/gemini 前缀返回空字符串
|
||||
{"空字符串", "", ""},
|
||||
{"非claude/gemini前缀 - gpt", "gpt-4", ""},
|
||||
{"非claude/gemini前缀 - llama", "llama-3", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -248,10 +203,10 @@ func TestAntigravityGatewayService_IsModelSupported(t *testing.T) {
|
||||
{"直接支持 - claude-sonnet-4-5", "claude-sonnet-4-5", true},
|
||||
{"直接支持 - gemini-3-flash", "gemini-3-flash", true},
|
||||
|
||||
// 可映射
|
||||
{"可映射 - claude-opus-4", "claude-opus-4", true},
|
||||
// 可映射(有明确前缀映射)
|
||||
{"可映射 - claude-opus-4-6", "claude-opus-4-6", true},
|
||||
|
||||
// 前缀透传
|
||||
// 前缀透传(claude 和 gemini 前缀)
|
||||
{"Gemini前缀", "gemini-unknown", true},
|
||||
{"Claude前缀", "claude-unknown", true},
|
||||
|
||||
@@ -267,3 +222,58 @@ func TestAntigravityGatewayService_IsModelSupported(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMapAntigravityModel_WildcardTargetEqualsRequest 测试通配符映射目标恰好等于请求模型名的 edge case
|
||||
// 例如 {"claude-*": "claude-sonnet-4-5"},请求 "claude-sonnet-4-5" 时应该通过
|
||||
func TestMapAntigravityModel_WildcardTargetEqualsRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modelMapping map[string]any
|
||||
requestedModel string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "wildcard target equals request model",
|
||||
modelMapping: map[string]any{"claude-*": "claude-sonnet-4-5"},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "wildcard target differs from request model",
|
||||
modelMapping: map[string]any{"claude-*": "claude-sonnet-4-5"},
|
||||
requestedModel: "claude-opus-4-6",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "wildcard no match",
|
||||
modelMapping: map[string]any{"claude-*": "claude-sonnet-4-5"},
|
||||
requestedModel: "gpt-4o",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "explicit passthrough same name",
|
||||
modelMapping: map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "multiple wildcards target equals one request",
|
||||
modelMapping: map[string]any{"claude-*": "claude-sonnet-4-5", "gemini-*": "gemini-2.5-flash"},
|
||||
requestedModel: "gemini-2.5-flash",
|
||||
expected: "gemini-2.5-flash",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": tt.modelMapping,
|
||||
},
|
||||
}
|
||||
got := mapAntigravityModel(account, tt.requestedModel)
|
||||
require.Equal(t, tt.expected, got, "mapAntigravityModel(%q) = %q, want %q", tt.requestedModel, got, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -57,15 +58,20 @@ func normalizeAntigravityModelName(model string) string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// IsSchedulableForModel 结合 Antigravity 配额域限流判断是否可调度
|
||||
// IsSchedulableForModel 结合 Antigravity 配额域限流判断是否可调度。
|
||||
// 保持旧签名以兼容既有调用方;默认使用 context.Background()。
|
||||
func (a *Account) IsSchedulableForModel(requestedModel string) bool {
|
||||
return a.IsSchedulableForModelWithContext(context.Background(), requestedModel)
|
||||
}
|
||||
|
||||
func (a *Account) IsSchedulableForModelWithContext(ctx context.Context, requestedModel string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
if !a.IsSchedulable() {
|
||||
return false
|
||||
}
|
||||
if a.isModelRateLimited(requestedModel) {
|
||||
if a.isModelRateLimitedWithContext(ctx, requestedModel) {
|
||||
return false
|
||||
}
|
||||
if a.Platform != PlatformAntigravity {
|
||||
@@ -132,3 +138,43 @@ func (a *Account) GetAntigravityScopeRateLimits() map[string]int64 {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetQuotaScopeRateLimitRemainingTime 获取模型域限流剩余时间
|
||||
// 返回 0 表示未限流或已过期
|
||||
func (a *Account) GetQuotaScopeRateLimitRemainingTime(requestedModel string) time.Duration {
|
||||
if a == nil || a.Platform != PlatformAntigravity {
|
||||
return 0
|
||||
}
|
||||
scope, ok := resolveAntigravityQuotaScope(requestedModel)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
resetAt := a.antigravityQuotaScopeResetAt(scope)
|
||||
if resetAt == nil {
|
||||
return 0
|
||||
}
|
||||
if remaining := time.Until(*resetAt); remaining > 0 {
|
||||
return remaining
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetRateLimitRemainingTime 获取限流剩余时间(模型限流和模型域限流取最大值)
|
||||
// 返回 0 表示未限流或已过期
|
||||
func (a *Account) GetRateLimitRemainingTime(requestedModel string) time.Duration {
|
||||
return a.GetRateLimitRemainingTimeWithContext(context.Background(), requestedModel)
|
||||
}
|
||||
|
||||
// GetRateLimitRemainingTimeWithContext 获取限流剩余时间(模型限流和模型域限流取最大值)
|
||||
// 返回 0 表示未限流或已过期
|
||||
func (a *Account) GetRateLimitRemainingTimeWithContext(ctx context.Context, requestedModel string) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
modelRemaining := a.GetModelRateLimitRemainingTimeWithContext(ctx, requestedModel)
|
||||
scopeRemaining := a.GetQuotaScopeRateLimitRemainingTime(requestedModel)
|
||||
if modelRemaining > scopeRemaining {
|
||||
return modelRemaining
|
||||
}
|
||||
return scopeRemaining
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,676 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockSmartRetryUpstream 用于 handleSmartRetry 测试的 mock upstream
|
||||
type mockSmartRetryUpstream struct {
|
||||
responses []*http.Response
|
||||
errors []error
|
||||
callIdx int
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
idx := m.callIdx
|
||||
m.calls = append(m.calls, req.URL.String())
|
||||
m.callIdx++
|
||||
if idx < len(m.responses) {
|
||||
return m.responses[idx], m.errors[idx]
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
return m.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_URLLevelRateLimit 测试 URL 级别限流切换
|
||||
func TestHandleSmartRetry_URLLevelRateLimit(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "acc-1",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
respBody := []byte(`{"error":{"message":"Resource has been exhausted"}}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test", "https://ag-2.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionContinueURL, result.action)
|
||||
require.Nil(t, result.resp)
|
||||
require.Nil(t, result.err)
|
||||
require.Nil(t, result.switchError)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_LongDelay_ReturnsSwitchError 测试 retryDelay >= 阈值时返回 switchError
|
||||
func TestHandleSmartRetry_LongDelay_ReturnsSwitchError(t *testing.T) {
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "acc-1",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 15s >= 7s 阈值,应该返回 switchError
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
accountRepo: repo,
|
||||
isStickySession: true,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.Nil(t, result.resp, "should not return resp when switchError is set")
|
||||
require.Nil(t, result.err)
|
||||
require.NotNil(t, result.switchError, "should return switchError for long delay")
|
||||
require.Equal(t, account.ID, result.switchError.OriginalAccountID)
|
||||
require.Equal(t, "claude-sonnet-4-5", result.switchError.RateLimitedModel)
|
||||
require.True(t, result.switchError.IsStickySession)
|
||||
|
||||
// 验证模型限流已设置
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_ShortDelay_SmartRetrySuccess 测试智能重试成功
|
||||
func TestHandleSmartRetry_ShortDelay_SmartRetrySuccess(t *testing.T) {
|
||||
successResp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||
}
|
||||
upstream := &mockSmartRetryUpstream{
|
||||
responses: []*http.Response{successResp},
|
||||
errors: []error{nil},
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Name: "acc-1",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 0.5s < 7s 阈值,应该触发智能重试
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-opus-4"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.5s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
httpUpstream: upstream,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.NotNil(t, result.resp, "should return successful response")
|
||||
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||
require.Nil(t, result.err)
|
||||
require.Nil(t, result.switchError, "should not return switchError on success")
|
||||
require.Len(t, upstream.calls, 1, "should have made one retry call")
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_ShortDelay_SmartRetryFailed_ReturnsSwitchError 测试智能重试失败后返回 switchError
|
||||
func TestHandleSmartRetry_ShortDelay_SmartRetryFailed_ReturnsSwitchError(t *testing.T) {
|
||||
// 智能重试后仍然返回 429(需要提供 3 个响应,因为智能重试最多 3 次)
|
||||
failRespBody := `{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||
]
|
||||
}
|
||||
}`
|
||||
failResp1 := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(failRespBody)),
|
||||
}
|
||||
failResp2 := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(failRespBody)),
|
||||
}
|
||||
failResp3 := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(failRespBody)),
|
||||
}
|
||||
upstream := &mockSmartRetryUpstream{
|
||||
responses: []*http.Response{failResp1, failResp2, failResp3},
|
||||
errors: []error{nil, nil, nil},
|
||||
}
|
||||
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 2,
|
||||
Name: "acc-2",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 3s < 7s 阈值,应该触发智能重试(最多 3 次)
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-flash"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
isStickySession: false,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.Nil(t, result.resp, "should not return resp when switchError is set")
|
||||
require.Nil(t, result.err)
|
||||
require.NotNil(t, result.switchError, "should return switchError after smart retry failed")
|
||||
require.Equal(t, account.ID, result.switchError.OriginalAccountID)
|
||||
require.Equal(t, "gemini-3-flash", result.switchError.RateLimitedModel)
|
||||
require.False(t, result.switchError.IsStickySession)
|
||||
|
||||
// 验证模型限流已设置
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
require.Equal(t, "gemini-3-flash", repo.modelRateLimitCalls[0].modelKey)
|
||||
require.Len(t, upstream.calls, 3, "should have made three retry calls (max attempts)")
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError 测试 503 MODEL_CAPACITY_EXHAUSTED 返回 switchError
|
||||
func TestHandleSmartRetry_503_ModelCapacityExhausted_ReturnsSwitchError(t *testing.T) {
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 3,
|
||||
Name: "acc-3",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 503 + MODEL_CAPACITY_EXHAUSTED + 39s >= 7s 阈值
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"code": 503,
|
||||
"status": "UNAVAILABLE",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-3-pro-high"}, "reason": "MODEL_CAPACITY_EXHAUSTED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "39s"}
|
||||
],
|
||||
"message": "No capacity available for model gemini-3-pro-high on the server"
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
accountRepo: repo,
|
||||
isStickySession: true,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.Nil(t, result.resp)
|
||||
require.Nil(t, result.err)
|
||||
require.NotNil(t, result.switchError, "should return switchError for 503 model capacity exhausted")
|
||||
require.Equal(t, account.ID, result.switchError.OriginalAccountID)
|
||||
require.Equal(t, "gemini-3-pro-high", result.switchError.RateLimitedModel)
|
||||
require.True(t, result.switchError.IsStickySession)
|
||||
|
||||
// 验证模型限流已设置
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
require.Equal(t, "gemini-3-pro-high", repo.modelRateLimitCalls[0].modelKey)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_NonAntigravityAccount_ContinuesDefaultLogic 测试非 Antigravity 平台账号走默认逻辑
|
||||
func TestHandleSmartRetry_NonAntigravityAccount_ContinuesDefaultLogic(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 4,
|
||||
Name: "acc-4",
|
||||
Type: AccountTypeAPIKey, // 非 Antigravity 平台账号
|
||||
Platform: PlatformAnthropic,
|
||||
}
|
||||
|
||||
// 即使是模型限流响应,非 OAuth 账号也应该走默认逻辑
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "15s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionContinue, result.action, "non-Antigravity platform account should continue default logic")
|
||||
require.Nil(t, result.resp)
|
||||
require.Nil(t, result.err)
|
||||
require.Nil(t, result.switchError)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_NonModelRateLimit_ContinuesDefaultLogic 测试非模型限流响应走默认逻辑
|
||||
func TestHandleSmartRetry_NonModelRateLimit_ContinuesDefaultLogic(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 5,
|
||||
Name: "acc-5",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 429 但没有 RATE_LIMIT_EXCEEDED 或 MODEL_CAPACITY_EXHAUSTED
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "5s"}
|
||||
],
|
||||
"message": "Quota exceeded"
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionContinue, result.action, "non-model rate limit should continue default logic")
|
||||
require.Nil(t, result.resp)
|
||||
require.Nil(t, result.err)
|
||||
require.Nil(t, result.switchError)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_ExactlyAtThreshold_ReturnsSwitchError 测试刚好等于阈值时返回 switchError
|
||||
func TestHandleSmartRetry_ExactlyAtThreshold_ReturnsSwitchError(t *testing.T) {
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 6,
|
||||
Name: "acc-6",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 刚好 7s = 7s 阈值,应该返回 switchError
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "gemini-pro"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "7s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
accountRepo: repo,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.Nil(t, result.resp)
|
||||
require.NotNil(t, result.switchError, "exactly at threshold should return switchError")
|
||||
require.Equal(t, "gemini-pro", result.switchError.RateLimitedModel)
|
||||
}
|
||||
|
||||
// TestAntigravityRetryLoop_HandleSmartRetry_SwitchError_Propagates 测试 switchError 正确传播到上层
|
||||
func TestAntigravityRetryLoop_HandleSmartRetry_SwitchError_Propagates(t *testing.T) {
|
||||
// 模拟 429 + 长延迟的响应
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-opus-4-6"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "30s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
rateLimitResp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
upstream := &mockSmartRetryUpstream{
|
||||
responses: []*http.Response{rateLimitResp},
|
||||
errors: []error{nil},
|
||||
}
|
||||
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 7,
|
||||
Name: "acc-7",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
Schedulable: true,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result, err := svc.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
httpUpstream: upstream,
|
||||
accountRepo: repo,
|
||||
isStickySession: true,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
require.Nil(t, result, "should not return result when switchError")
|
||||
require.NotNil(t, err, "should return error")
|
||||
|
||||
var switchErr *AntigravityAccountSwitchError
|
||||
require.ErrorAs(t, err, &switchErr, "error should be AntigravityAccountSwitchError")
|
||||
require.Equal(t, account.ID, switchErr.OriginalAccountID)
|
||||
require.Equal(t, "claude-opus-4-6", switchErr.RateLimitedModel)
|
||||
require.True(t, switchErr.IsStickySession)
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_NetworkError_ContinuesRetry 测试网络错误时继续重试
|
||||
func TestHandleSmartRetry_NetworkError_ContinuesRetry(t *testing.T) {
|
||||
// 第一次网络错误,第二次成功
|
||||
successResp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(`{"result":"ok"}`)),
|
||||
}
|
||||
upstream := &mockSmartRetryUpstream{
|
||||
responses: []*http.Response{nil, successResp}, // 第一次返回 nil(模拟网络错误)
|
||||
errors: []error{nil, nil}, // mock 不返回 error,靠 nil response 触发
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 8,
|
||||
Name: "acc-8",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 0.1s < 7s 阈值,应该触发智能重试
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"},
|
||||
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "0.1s"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
httpUpstream: upstream,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.NotNil(t, result.resp, "should return successful response after network error recovery")
|
||||
require.Equal(t, http.StatusOK, result.resp.StatusCode)
|
||||
require.Nil(t, result.switchError, "should not return switchError on success")
|
||||
require.Len(t, upstream.calls, 2, "should have made two retry calls")
|
||||
}
|
||||
|
||||
// TestHandleSmartRetry_NoRetryDelay_UsesDefaultRateLimit 测试无 retryDelay 时使用默认 1 分钟限流
|
||||
func TestHandleSmartRetry_NoRetryDelay_UsesDefaultRateLimit(t *testing.T) {
|
||||
repo := &stubAntigravityAccountRepo{}
|
||||
account := &Account{
|
||||
ID: 9,
|
||||
Name: "acc-9",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
}
|
||||
|
||||
// 429 + RATE_LIMIT_EXCEEDED + 无 retryDelay → 使用默认 1 分钟限流
|
||||
respBody := []byte(`{
|
||||
"error": {
|
||||
"status": "RESOURCE_EXHAUSTED",
|
||||
"details": [
|
||||
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "metadata": {"model": "claude-sonnet-4-5"}, "reason": "RATE_LIMIT_EXCEEDED"}
|
||||
],
|
||||
"message": "You have exhausted your capacity on this model."
|
||||
}
|
||||
}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
}
|
||||
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[test]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"input":"test"}`),
|
||||
accountRepo: repo,
|
||||
isStickySession: true,
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
availableURLs := []string{"https://ag-1.test"}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, respBody, "https://ag-1.test", 0, availableURLs)
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionBreakWithResp, result.action)
|
||||
require.Nil(t, result.resp, "should not return resp when switchError is set")
|
||||
require.NotNil(t, result.switchError, "should return switchError for no retryDelay")
|
||||
require.Equal(t, "claude-sonnet-4-5", result.switchError.RateLimitedModel)
|
||||
require.True(t, result.switchError.IsStickySession)
|
||||
|
||||
// 验证模型限流已设置
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
require.Equal(t, "claude-sonnet-4-5", repo.modelRateLimitCalls[0].modelKey)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyThinkingModelSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mappedModel string
|
||||
thinkingEnabled bool
|
||||
expected string
|
||||
}{
|
||||
// Thinking 未开启:保持原样
|
||||
{
|
||||
name: "thinking disabled - claude-sonnet-4-5 unchanged",
|
||||
mappedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: false,
|
||||
expected: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
name: "thinking disabled - other model unchanged",
|
||||
mappedModel: "claude-opus-4-6-thinking",
|
||||
thinkingEnabled: false,
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
|
||||
// Thinking 开启 + claude-sonnet-4-5:自动添加后缀
|
||||
{
|
||||
name: "thinking enabled - claude-sonnet-4-5 becomes thinking version",
|
||||
mappedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: true,
|
||||
expected: "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
|
||||
// Thinking 开启 + 其他模型:保持原样
|
||||
{
|
||||
name: "thinking enabled - claude-sonnet-4-5-thinking unchanged",
|
||||
mappedModel: "claude-sonnet-4-5-thinking",
|
||||
thinkingEnabled: true,
|
||||
expected: "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
{
|
||||
name: "thinking enabled - claude-opus-4-6-thinking unchanged",
|
||||
mappedModel: "claude-opus-4-6-thinking",
|
||||
thinkingEnabled: true,
|
||||
expected: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{
|
||||
name: "thinking enabled - gemini model unchanged",
|
||||
mappedModel: "gemini-3-flash",
|
||||
thinkingEnabled: true,
|
||||
expected: "gemini-3-flash",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := applyThinkingModelSuffix(tt.mappedModel, tt.thinkingEnabled)
|
||||
if result != tt.expected {
|
||||
t.Errorf("applyThinkingModelSuffix(%q, %v) = %q, want %q",
|
||||
tt.mappedModel, tt.thinkingEnabled, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,18 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
||||
if account == nil {
|
||||
return "", errors.New("account is nil")
|
||||
}
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
if account.Platform != PlatformAntigravity {
|
||||
return "", errors.New("not an antigravity account")
|
||||
}
|
||||
// upstream 类型:直接从 credentials 读取 api_key,不走 OAuth 刷新流程
|
||||
if account.Type == AccountTypeUpstream {
|
||||
apiKey := account.GetCredential("api_key")
|
||||
if apiKey == "" {
|
||||
return "", errors.New("upstream account missing api_key in credentials")
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
if account.Type != AccountTypeOAuth {
|
||||
return "", errors.New("not an antigravity oauth account")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAntigravityTokenProvider_GetAccessToken_Upstream(t *testing.T) {
|
||||
provider := &AntigravityTokenProvider{}
|
||||
|
||||
t.Run("upstream account with valid api_key", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeUpstream,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test-key-12345",
|
||||
},
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-test-key-12345", token)
|
||||
})
|
||||
|
||||
t.Run("upstream account missing api_key", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeUpstream,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upstream account missing api_key")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("upstream account with empty api_key", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeUpstream,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "",
|
||||
},
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upstream account missing api_key")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("upstream account with nil credentials", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeUpstream,
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "upstream account missing api_key")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAntigravityTokenProvider_GetAccessToken_Guards(t *testing.T) {
|
||||
provider := &AntigravityTokenProvider{}
|
||||
|
||||
t.Run("nil account", func(t *testing.T) {
|
||||
token, err := provider.GetAccessToken(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "account is nil")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("non-antigravity platform", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not an antigravity account")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("unsupported account type", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeAPIKey,
|
||||
}
|
||||
token, err := provider.GetAccessToken(context.Background(), account)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not an antigravity oauth account")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -23,12 +22,6 @@ type apiKeyAuthCacheConfig struct {
|
||||
singleflight bool
|
||||
}
|
||||
|
||||
var (
|
||||
jitterRandMu sync.Mutex
|
||||
// 认证缓存抖动使用独立随机源,避免全局 Seed
|
||||
jitterRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
)
|
||||
|
||||
func newAPIKeyAuthCacheConfig(cfg *config.Config) apiKeyAuthCacheConfig {
|
||||
if cfg == nil {
|
||||
return apiKeyAuthCacheConfig{}
|
||||
@@ -56,6 +49,8 @@ func (c apiKeyAuthCacheConfig) negativeEnabled() bool {
|
||||
return c.negativeTTL > 0
|
||||
}
|
||||
|
||||
// jitterTTL 为缓存 TTL 添加抖动,避免多个请求在同一时刻同时过期触发集中回源。
|
||||
// 这里直接使用 rand/v2 的顶层函数:并发安全,无需全局互斥锁。
|
||||
func (c apiKeyAuthCacheConfig) jitterTTL(ttl time.Duration) time.Duration {
|
||||
if ttl <= 0 {
|
||||
return ttl
|
||||
@@ -68,9 +63,7 @@ func (c apiKeyAuthCacheConfig) jitterTTL(ttl time.Duration) time.Duration {
|
||||
percent = 100
|
||||
}
|
||||
delta := float64(percent) / 100
|
||||
jitterRandMu.Lock()
|
||||
randVal := jitterRand.Float64()
|
||||
jitterRandMu.Unlock()
|
||||
randVal := rand.Float64()
|
||||
factor := 1 - delta + randVal*(2*delta)
|
||||
if factor <= 0 {
|
||||
return ttl
|
||||
|
||||
@@ -56,7 +56,8 @@ func NewClaudeCodeValidator() *ClaudeCodeValidator {
|
||||
//
|
||||
// Step 1: User-Agent 检查 (必需) - 必须是 claude-cli/x.x.x
|
||||
// Step 2: 对于非 messages 路径,只要 UA 匹配就通过
|
||||
// Step 3: 对于 messages 路径,进行严格验证:
|
||||
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过(UA 已验证)
|
||||
// Step 4: 对于 messages 路径,进行严格验证:
|
||||
// - System prompt 相似度检查
|
||||
// - X-App header 检查
|
||||
// - anthropic-beta header 检查
|
||||
@@ -75,14 +76,20 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// Step 3: messages 路径,进行严格验证
|
||||
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过
|
||||
// 这类请求用于 Claude Code 验证 API 连通性,不携带 system prompt
|
||||
if isMaxTokensOneHaiku, ok := r.Context().Value(ctxkey.IsMaxTokensOneHaikuRequest).(bool); ok && isMaxTokensOneHaiku {
|
||||
return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
|
||||
}
|
||||
|
||||
// 3.1 检查 system prompt 相似度
|
||||
// Step 4: messages 路径,进行严格验证
|
||||
|
||||
// 4.1 检查 system prompt 相似度
|
||||
if !v.hasClaudeCodeSystemPrompt(body) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3.2 检查必需的 headers(值不为空即可)
|
||||
// 4.2 检查必需的 headers(值不为空即可)
|
||||
xApp := r.Header.Get("X-App")
|
||||
if xApp == "" {
|
||||
return false
|
||||
@@ -98,7 +105,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
|
||||
return false
|
||||
}
|
||||
|
||||
// 3.3 验证 metadata.user_id
|
||||
// 4.3 验证 metadata.user_id
|
||||
if body == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeCodeValidator_ProbeBypass(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
|
||||
|
||||
ok := validator.Validate(req, map[string]any{
|
||||
"model": "claude-haiku-4-5",
|
||||
"max_tokens": 1,
|
||||
})
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestClaudeCodeValidator_ProbeBypassRequiresUA(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "curl/8.0.0")
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxkey.IsMaxTokensOneHaikuRequest, true))
|
||||
|
||||
ok := validator.Validate(req, map[string]any{
|
||||
"model": "claude-haiku-4-5",
|
||||
"max_tokens": 1,
|
||||
})
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestClaudeCodeValidator_MessagesWithoutProbeStillNeedStrictValidation(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
|
||||
|
||||
ok := validator.Validate(req, map[string]any{
|
||||
"model": "claude-haiku-4-5",
|
||||
"max_tokens": 1,
|
||||
})
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
|
||||
validator := NewClaudeCodeValidator()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/models", nil)
|
||||
req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)")
|
||||
|
||||
ok := validator.Validate(req, nil)
|
||||
require.True(t, ok)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type ConcurrencyCache interface {
|
||||
|
||||
// 批量负载查询(只读)
|
||||
GetAccountsLoadBatch(ctx context.Context, accounts []AccountWithConcurrency) (map[int64]*AccountLoadInfo, error)
|
||||
GetUsersLoadBatch(ctx context.Context, users []UserWithConcurrency) (map[int64]*UserLoadInfo, error)
|
||||
|
||||
// 清理过期槽位(后台任务)
|
||||
CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error
|
||||
@@ -77,6 +78,11 @@ type AccountWithConcurrency struct {
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
type UserWithConcurrency struct {
|
||||
ID int64
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
type AccountLoadInfo struct {
|
||||
AccountID int64
|
||||
CurrentConcurrency int
|
||||
@@ -84,6 +90,13 @@ type AccountLoadInfo struct {
|
||||
LoadRate int // 0-100+ (percent)
|
||||
}
|
||||
|
||||
type UserLoadInfo struct {
|
||||
UserID int64
|
||||
CurrentConcurrency int
|
||||
WaitingCount int
|
||||
LoadRate int // 0-100+ (percent)
|
||||
}
|
||||
|
||||
// AcquireAccountSlot attempts to acquire a concurrency slot for an account.
|
||||
// If the account is at max concurrency, it waits until a slot is available or timeout.
|
||||
// Returns a release function that MUST be called when the request completes.
|
||||
@@ -253,6 +266,14 @@ func (s *ConcurrencyService) GetAccountsLoadBatch(ctx context.Context, accounts
|
||||
return s.cache.GetAccountsLoadBatch(ctx, accounts)
|
||||
}
|
||||
|
||||
// GetUsersLoadBatch returns load info for multiple users.
|
||||
func (s *ConcurrencyService) GetUsersLoadBatch(ctx context.Context, users []UserWithConcurrency) (map[int64]*UserLoadInfo, error) {
|
||||
if s.cache == nil {
|
||||
return map[int64]*UserLoadInfo{}, nil
|
||||
}
|
||||
return s.cache.GetUsersLoadBatch(ctx, users)
|
||||
}
|
||||
|
||||
// CleanupExpiredAccountSlots removes expired slots for one account (background task).
|
||||
func (s *ConcurrencyService) CleanupExpiredAccountSlots(ctx context.Context, accountID int64) error {
|
||||
if s.cache == nil {
|
||||
|
||||
@@ -319,16 +319,16 @@ func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, end
|
||||
return trend, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs)
|
||||
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchUserUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get batch user usage stats: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs)
|
||||
func (s *DashboardService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
const errorPassthroughServiceContextKey = "error_passthrough_service"
|
||||
|
||||
// BindErrorPassthroughService 将错误透传服务绑定到请求上下文,供 service 层在非 failover 场景下复用规则。
|
||||
func BindErrorPassthroughService(c *gin.Context, svc *ErrorPassthroughService) {
|
||||
if c == nil || svc == nil {
|
||||
return
|
||||
}
|
||||
c.Set(errorPassthroughServiceContextKey, svc)
|
||||
}
|
||||
|
||||
func getBoundErrorPassthroughService(c *gin.Context) *ErrorPassthroughService {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
v, ok := c.Get(errorPassthroughServiceContextKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
svc, ok := v.(*ErrorPassthroughService)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// applyErrorPassthroughRule 按规则改写错误响应;未命中时返回默认响应参数。
|
||||
func applyErrorPassthroughRule(
|
||||
c *gin.Context,
|
||||
platform string,
|
||||
upstreamStatus int,
|
||||
responseBody []byte,
|
||||
defaultStatus int,
|
||||
defaultErrType string,
|
||||
defaultErrMsg string,
|
||||
) (status int, errType string, errMsg string, matched bool) {
|
||||
status = defaultStatus
|
||||
errType = defaultErrType
|
||||
errMsg = defaultErrMsg
|
||||
|
||||
svc := getBoundErrorPassthroughService(c)
|
||||
if svc == nil {
|
||||
return status, errType, errMsg, false
|
||||
}
|
||||
|
||||
rule := svc.MatchRule(platform, upstreamStatus, responseBody)
|
||||
if rule == nil {
|
||||
return status, errType, errMsg, false
|
||||
}
|
||||
|
||||
status = upstreamStatus
|
||||
if !rule.PassthroughCode && rule.ResponseCode != nil {
|
||||
status = *rule.ResponseCode
|
||||
}
|
||||
|
||||
errMsg = ExtractUpstreamErrorMessage(responseBody)
|
||||
if !rule.PassthroughBody && rule.CustomMessage != nil {
|
||||
errMsg = *rule.CustomMessage
|
||||
}
|
||||
|
||||
// 与现有 failover 场景保持一致:命中规则时统一返回 upstream_error。
|
||||
errType = "upstream_error"
|
||||
return status, errType, errMsg, true
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyErrorPassthroughRule_NoBoundService(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformAnthropic,
|
||||
http.StatusUnprocessableEntity,
|
||||
[]byte(`{"error":{"message":"invalid schema"}}`),
|
||||
http.StatusBadGateway,
|
||||
"upstream_error",
|
||||
"Upstream request failed",
|
||||
)
|
||||
|
||||
assert.False(t, matched)
|
||||
assert.Equal(t, http.StatusBadGateway, status)
|
||||
assert.Equal(t, "upstream_error", errType)
|
||||
assert.Equal(t, "Upstream request failed", errMsg)
|
||||
}
|
||||
|
||||
func TestGatewayHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &GatewayService{}
|
||||
respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 11, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "upstream_error", errField["type"])
|
||||
assert.Equal(t, "Upstream request failed", errField["message"])
|
||||
}
|
||||
|
||||
func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "upstream_error", errField["type"])
|
||||
assert.Equal(t, "Upstream request failed", errField["message"])
|
||||
}
|
||||
|
||||
func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
svc := &GeminiMessagesCompatService{}
|
||||
respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
|
||||
account := &Account{ID: 13, Platform: PlatformGemini, Type: AccountTypeAPIKey}
|
||||
|
||||
err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-2", respBody)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "invalid_request_error", errField["type"])
|
||||
assert.Equal(t, "Upstream request failed", errField["message"])
|
||||
}
|
||||
|
||||
func TestGatewayHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
ruleSvc := &ErrorPassthroughService{}
|
||||
ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "上游请求失败")})
|
||||
BindErrorPassthroughService(c, ruleSvc)
|
||||
|
||||
svc := &GatewayService{}
|
||||
respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusTeapot, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "upstream_error", errField["type"])
|
||||
assert.Equal(t, "上游请求失败", errField["message"])
|
||||
}
|
||||
|
||||
func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
ruleSvc := &ErrorPassthroughService{}
|
||||
ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "OpenAI上游失败")})
|
||||
BindErrorPassthroughService(c, ruleSvc)
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnprocessableEntity,
|
||||
Body: io.NopCloser(bytes.NewReader(respBody)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
|
||||
_, err := svc.handleErrorResponse(context.Background(), resp, c, account)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusTeapot, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "upstream_error", errField["type"])
|
||||
assert.Equal(t, "OpenAI上游失败", errField["message"])
|
||||
}
|
||||
|
||||
func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
|
||||
ruleSvc := &ErrorPassthroughService{}
|
||||
ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "Gemini上游失败")})
|
||||
BindErrorPassthroughService(c, ruleSvc)
|
||||
|
||||
svc := &GeminiMessagesCompatService{}
|
||||
respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
|
||||
account := &Account{ID: 3, Platform: PlatformGemini, Type: AccountTypeAPIKey}
|
||||
|
||||
err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-1", respBody)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, http.StatusTeapot, rec.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
errField, ok := payload["error"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "upstream_error", errField["type"])
|
||||
assert.Equal(t, "Gemini上游失败", errField["message"])
|
||||
}
|
||||
|
||||
func newNonFailoverPassthroughRule(statusCode int, keyword string, respCode int, customMessage string) *model.ErrorPassthroughRule {
|
||||
return &model.ErrorPassthroughRule{
|
||||
ID: 1,
|
||||
Name: "non-failover-rule",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{statusCode},
|
||||
Keywords: []string{keyword},
|
||||
MatchMode: model.MatchModeAll,
|
||||
PassthroughCode: false,
|
||||
ResponseCode: &respCode,
|
||||
PassthroughBody: false,
|
||||
CustomMessage: &customMessage,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
)
|
||||
@@ -60,8 +61,11 @@ func NewErrorPassthroughService(
|
||||
|
||||
// 启动时加载规则到本地缓存
|
||||
ctx := context.Background()
|
||||
if err := svc.refreshLocalCache(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to load rules on startup: %v", err)
|
||||
if err := svc.reloadRulesFromDB(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to load rules from DB on startup: %v", err)
|
||||
if fallbackErr := svc.refreshLocalCache(ctx); fallbackErr != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to load rules from cache fallback on startup: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅缓存更新通知
|
||||
@@ -98,7 +102,9 @@ func (s *ErrorPassthroughService) Create(ctx context.Context, rule *model.ErrorP
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.invalidateAndNotify(ctx)
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return created, nil
|
||||
}
|
||||
@@ -115,7 +121,9 @@ func (s *ErrorPassthroughService) Update(ctx context.Context, rule *model.ErrorP
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.invalidateAndNotify(ctx)
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
@@ -127,7 +135,9 @@ func (s *ErrorPassthroughService) Delete(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
// 刷新缓存
|
||||
s.invalidateAndNotify(ctx)
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -189,7 +199,12 @@ func (s *ErrorPassthroughService) refreshLocalCache(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库加载(repo.List 已按 priority 排序)
|
||||
return s.reloadRulesFromDB(ctx)
|
||||
}
|
||||
|
||||
// 从数据库加载(repo.List 已按 priority 排序)
|
||||
// 注意:该方法会绕过 cache.Get,确保拿到数据库最新值。
|
||||
func (s *ErrorPassthroughService) reloadRulesFromDB(ctx context.Context) error {
|
||||
rules, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -222,11 +237,32 @@ func (s *ErrorPassthroughService) setLocalCache(rules []*model.ErrorPassthroughR
|
||||
s.localCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// clearLocalCache 清空本地缓存,避免刷新失败时继续命中陈旧规则。
|
||||
func (s *ErrorPassthroughService) clearLocalCache() {
|
||||
s.localCacheMu.Lock()
|
||||
s.localCache = nil
|
||||
s.localCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// newCacheRefreshContext 为写路径缓存同步创建独立上下文,避免受请求取消影响。
|
||||
func (s *ErrorPassthroughService) newCacheRefreshContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Second)
|
||||
}
|
||||
|
||||
// invalidateAndNotify 使缓存失效并通知其他实例
|
||||
func (s *ErrorPassthroughService) invalidateAndNotify(ctx context.Context) {
|
||||
// 先失效缓存,避免后续刷新读到陈旧规则。
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Invalidate(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to invalidate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新本地缓存
|
||||
if err := s.refreshLocalCache(ctx); err != nil {
|
||||
if err := s.reloadRulesFromDB(ctx); err != nil {
|
||||
log.Printf("[ErrorPassthroughService] Failed to refresh local cache: %v", err)
|
||||
// 刷新失败时清空本地缓存,避免继续使用陈旧规则。
|
||||
s.clearLocalCache()
|
||||
}
|
||||
|
||||
// 通知其他实例
|
||||
|
||||
@@ -4,6 +4,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,14 +15,81 @@ import (
|
||||
|
||||
// mockErrorPassthroughRepo 用于测试的 mock repository
|
||||
type mockErrorPassthroughRepo struct {
|
||||
rules []*model.ErrorPassthroughRule
|
||||
rules []*model.ErrorPassthroughRule
|
||||
listErr error
|
||||
getErr error
|
||||
createErr error
|
||||
updateErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
type mockErrorPassthroughCache struct {
|
||||
rules []*model.ErrorPassthroughRule
|
||||
hasData bool
|
||||
getCalled int
|
||||
setCalled int
|
||||
invalidateCalled int
|
||||
notifyCalled int
|
||||
}
|
||||
|
||||
func newMockErrorPassthroughCache(rules []*model.ErrorPassthroughRule, hasData bool) *mockErrorPassthroughCache {
|
||||
return &mockErrorPassthroughCache{
|
||||
rules: cloneRules(rules),
|
||||
hasData: hasData,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughCache) Get(ctx context.Context) ([]*model.ErrorPassthroughRule, bool) {
|
||||
m.getCalled++
|
||||
if !m.hasData {
|
||||
return nil, false
|
||||
}
|
||||
return cloneRules(m.rules), true
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughCache) Set(ctx context.Context, rules []*model.ErrorPassthroughRule) error {
|
||||
m.setCalled++
|
||||
m.rules = cloneRules(rules)
|
||||
m.hasData = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughCache) Invalidate(ctx context.Context) error {
|
||||
m.invalidateCalled++
|
||||
m.rules = nil
|
||||
m.hasData = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughCache) NotifyUpdate(ctx context.Context) error {
|
||||
m.notifyCalled++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughCache) SubscribeUpdates(ctx context.Context, handler func()) {
|
||||
// 单测中无需订阅行为
|
||||
}
|
||||
|
||||
func cloneRules(rules []*model.ErrorPassthroughRule) []*model.ErrorPassthroughRule {
|
||||
if rules == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]*model.ErrorPassthroughRule, len(rules))
|
||||
copy(out, rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughRepo) List(ctx context.Context) ([]*model.ErrorPassthroughRule, error) {
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return m.rules, nil
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughRepo) GetByID(ctx context.Context, id int64) (*model.ErrorPassthroughRule, error) {
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
for _, r := range m.rules {
|
||||
if r.ID == id {
|
||||
return r, nil
|
||||
@@ -31,12 +99,18 @@ func (m *mockErrorPassthroughRepo) GetByID(ctx context.Context, id int64) (*mode
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughRepo) Create(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
|
||||
if m.createErr != nil {
|
||||
return nil, m.createErr
|
||||
}
|
||||
rule.ID = int64(len(m.rules) + 1)
|
||||
m.rules = append(m.rules, rule)
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughRepo) Update(ctx context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
|
||||
if m.updateErr != nil {
|
||||
return nil, m.updateErr
|
||||
}
|
||||
for i, r := range m.rules {
|
||||
if r.ID == rule.ID {
|
||||
m.rules[i] = rule
|
||||
@@ -47,6 +121,9 @@ func (m *mockErrorPassthroughRepo) Update(ctx context.Context, rule *model.Error
|
||||
}
|
||||
|
||||
func (m *mockErrorPassthroughRepo) Delete(ctx context.Context, id int64) error {
|
||||
if m.deleteErr != nil {
|
||||
return m.deleteErr
|
||||
}
|
||||
for i, r := range m.rules {
|
||||
if r.ID == id {
|
||||
m.rules = append(m.rules[:i], m.rules[i+1:]...)
|
||||
@@ -750,6 +827,158 @@ func TestErrorPassthroughRule_Validate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试写路径缓存刷新(Create/Update/Delete)
|
||||
// =============================================================================
|
||||
|
||||
func TestCreate_ForceRefreshCacheAfterWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
staleRule := newPassthroughRuleForWritePathTest(99, "service temporarily unavailable after multiple", "旧缓存消息")
|
||||
repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{}}
|
||||
cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
|
||||
|
||||
svc := &ErrorPassthroughService{repo: repo, cache: cache}
|
||||
svc.setLocalCache([]*model.ErrorPassthroughRule{staleRule})
|
||||
|
||||
newRule := newPassthroughRuleForWritePathTest(0, "service temporarily unavailable after multiple", "上游请求失败")
|
||||
created, err := svc.Create(ctx, newRule)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
body := []byte(`{"message":"Service temporarily unavailable after multiple retries, please try again later"}`)
|
||||
matched := svc.MatchRule("anthropic", 503, body)
|
||||
require.NotNil(t, matched)
|
||||
assert.Equal(t, created.ID, matched.ID)
|
||||
if assert.NotNil(t, matched.CustomMessage) {
|
||||
assert.Equal(t, "上游请求失败", *matched.CustomMessage)
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
|
||||
assert.Equal(t, 1, cache.invalidateCalled)
|
||||
assert.Equal(t, 1, cache.setCalled)
|
||||
assert.Equal(t, 1, cache.notifyCalled)
|
||||
}
|
||||
|
||||
func TestUpdate_ForceRefreshCacheAfterWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
originalRule := newPassthroughRuleForWritePathTest(1, "old keyword", "旧消息")
|
||||
repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{originalRule}}
|
||||
cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{originalRule}, true)
|
||||
|
||||
svc := &ErrorPassthroughService{repo: repo, cache: cache}
|
||||
svc.setLocalCache([]*model.ErrorPassthroughRule{originalRule})
|
||||
|
||||
updatedRule := newPassthroughRuleForWritePathTest(1, "new keyword", "新消息")
|
||||
_, err := svc.Update(ctx, updatedRule)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldBody := []byte(`{"message":"old keyword"}`)
|
||||
oldMatched := svc.MatchRule("anthropic", 503, oldBody)
|
||||
assert.Nil(t, oldMatched, "更新后旧关键词不应继续命中")
|
||||
|
||||
newBody := []byte(`{"message":"new keyword"}`)
|
||||
newMatched := svc.MatchRule("anthropic", 503, newBody)
|
||||
require.NotNil(t, newMatched)
|
||||
if assert.NotNil(t, newMatched.CustomMessage) {
|
||||
assert.Equal(t, "新消息", *newMatched.CustomMessage)
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
|
||||
assert.Equal(t, 1, cache.invalidateCalled)
|
||||
assert.Equal(t, 1, cache.setCalled)
|
||||
assert.Equal(t, 1, cache.notifyCalled)
|
||||
}
|
||||
|
||||
func TestDelete_ForceRefreshCacheAfterWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
rule := newPassthroughRuleForWritePathTest(1, "to be deleted", "删除前消息")
|
||||
repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{rule}}
|
||||
cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{rule}, true)
|
||||
|
||||
svc := &ErrorPassthroughService{repo: repo, cache: cache}
|
||||
svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
|
||||
|
||||
err := svc.Delete(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := []byte(`{"message":"to be deleted"}`)
|
||||
matched := svc.MatchRule("anthropic", 503, body)
|
||||
assert.Nil(t, matched, "删除后规则不应再命中")
|
||||
|
||||
assert.Equal(t, 0, cache.getCalled, "写路径刷新不应依赖 cache.Get")
|
||||
assert.Equal(t, 1, cache.invalidateCalled)
|
||||
assert.Equal(t, 1, cache.setCalled)
|
||||
assert.Equal(t, 1, cache.notifyCalled)
|
||||
}
|
||||
|
||||
func TestNewService_StartupReloadFromDBToHealStaleCache(t *testing.T) {
|
||||
staleRule := newPassthroughRuleForWritePathTest(99, "stale keyword", "旧缓存消息")
|
||||
latestRule := newPassthroughRuleForWritePathTest(1, "fresh keyword", "最新消息")
|
||||
|
||||
repo := &mockErrorPassthroughRepo{rules: []*model.ErrorPassthroughRule{latestRule}}
|
||||
cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
|
||||
|
||||
svc := NewErrorPassthroughService(repo, cache)
|
||||
|
||||
matchedFresh := svc.MatchRule("anthropic", 503, []byte(`{"message":"fresh keyword"}`))
|
||||
require.NotNil(t, matchedFresh)
|
||||
assert.Equal(t, int64(1), matchedFresh.ID)
|
||||
|
||||
matchedStale := svc.MatchRule("anthropic", 503, []byte(`{"message":"stale keyword"}`))
|
||||
assert.Nil(t, matchedStale, "启动后应以 DB 最新规则覆盖旧缓存")
|
||||
|
||||
assert.Equal(t, 0, cache.getCalled, "启动强制 DB 刷新不应依赖 cache.Get")
|
||||
assert.Equal(t, 1, cache.setCalled, "启动后应回写缓存,覆盖陈旧缓存")
|
||||
}
|
||||
|
||||
func TestUpdate_RefreshFailureShouldNotKeepStaleEnabledRule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
staleRule := newPassthroughRuleForWritePathTest(1, "service temporarily unavailable after multiple", "旧缓存消息")
|
||||
repo := &mockErrorPassthroughRepo{
|
||||
rules: []*model.ErrorPassthroughRule{staleRule},
|
||||
listErr: errors.New("db list failed"),
|
||||
}
|
||||
cache := newMockErrorPassthroughCache([]*model.ErrorPassthroughRule{staleRule}, true)
|
||||
|
||||
svc := &ErrorPassthroughService{repo: repo, cache: cache}
|
||||
svc.setLocalCache([]*model.ErrorPassthroughRule{staleRule})
|
||||
|
||||
disabledRule := *staleRule
|
||||
disabledRule.Enabled = false
|
||||
_, err := svc.Update(ctx, &disabledRule)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := []byte(`{"message":"Service temporarily unavailable after multiple retries, please try again later"}`)
|
||||
matched := svc.MatchRule("anthropic", 503, body)
|
||||
assert.Nil(t, matched, "刷新失败时不应继续命中旧的启用规则")
|
||||
|
||||
svc.localCacheMu.RLock()
|
||||
assert.Nil(t, svc.localCache, "刷新失败后应清空本地缓存,避免误命中")
|
||||
svc.localCacheMu.RUnlock()
|
||||
}
|
||||
|
||||
func newPassthroughRuleForWritePathTest(id int64, keyword, customMsg string) *model.ErrorPassthroughRule {
|
||||
responseCode := 503
|
||||
rule := &model.ErrorPassthroughRule{
|
||||
ID: id,
|
||||
Name: "write-path-cache-refresh",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{503},
|
||||
Keywords: []string{keyword},
|
||||
MatchMode: model.MatchModeAll,
|
||||
PassthroughCode: false,
|
||||
ResponseCode: &responseCode,
|
||||
PassthroughBody: false,
|
||||
CustomMessage: &customMsg,
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func testIntPtr(i int) *int { return &i }
|
||||
func testStrPtr(s string) *string { return &s }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsForceCacheBilling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "context without force cache billing",
|
||||
ctx: context.Background(),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "context with force cache billing set to true",
|
||||
ctx: context.WithValue(context.Background(), ForceCacheBillingContextKey, true),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "context with force cache billing set to false",
|
||||
ctx: context.WithValue(context.Background(), ForceCacheBillingContextKey, false),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "context with wrong type value",
|
||||
ctx: context.WithValue(context.Background(), ForceCacheBillingContextKey, "true"),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsForceCacheBilling(tt.ctx)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsForceCacheBilling() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithForceCacheBilling(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 原始上下文没有标记
|
||||
if IsForceCacheBilling(ctx) {
|
||||
t.Error("original context should not have force cache billing")
|
||||
}
|
||||
|
||||
// 使用 WithForceCacheBilling 后应该有标记
|
||||
newCtx := WithForceCacheBilling(ctx)
|
||||
if !IsForceCacheBilling(newCtx) {
|
||||
t.Error("new context should have force cache billing")
|
||||
}
|
||||
|
||||
// 原始上下文应该不受影响
|
||||
if IsForceCacheBilling(ctx) {
|
||||
t.Error("original context should still not have force cache billing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceCacheBilling_TokenConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forceCacheBilling bool
|
||||
inputTokens int
|
||||
cacheReadInputTokens int
|
||||
expectedInputTokens int
|
||||
expectedCacheReadTokens int
|
||||
}{
|
||||
{
|
||||
name: "force cache billing converts input to cache_read",
|
||||
forceCacheBilling: true,
|
||||
inputTokens: 1000,
|
||||
cacheReadInputTokens: 500,
|
||||
expectedInputTokens: 0,
|
||||
expectedCacheReadTokens: 1500, // 500 + 1000
|
||||
},
|
||||
{
|
||||
name: "no force cache billing keeps tokens unchanged",
|
||||
forceCacheBilling: false,
|
||||
inputTokens: 1000,
|
||||
cacheReadInputTokens: 500,
|
||||
expectedInputTokens: 1000,
|
||||
expectedCacheReadTokens: 500,
|
||||
},
|
||||
{
|
||||
name: "force cache billing with zero input tokens does nothing",
|
||||
forceCacheBilling: true,
|
||||
inputTokens: 0,
|
||||
cacheReadInputTokens: 500,
|
||||
expectedInputTokens: 0,
|
||||
expectedCacheReadTokens: 500,
|
||||
},
|
||||
{
|
||||
name: "force cache billing with zero cache_read tokens",
|
||||
forceCacheBilling: true,
|
||||
inputTokens: 1000,
|
||||
cacheReadInputTokens: 0,
|
||||
expectedInputTokens: 0,
|
||||
expectedCacheReadTokens: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 模拟 RecordUsage 中的 ForceCacheBilling 逻辑
|
||||
usage := ClaudeUsage{
|
||||
InputTokens: tt.inputTokens,
|
||||
CacheReadInputTokens: tt.cacheReadInputTokens,
|
||||
}
|
||||
|
||||
// 这是 RecordUsage 中的实际逻辑
|
||||
if tt.forceCacheBilling && usage.InputTokens > 0 {
|
||||
usage.CacheReadInputTokens += usage.InputTokens
|
||||
usage.InputTokens = 0
|
||||
}
|
||||
|
||||
if usage.InputTokens != tt.expectedInputTokens {
|
||||
t.Errorf("InputTokens = %d, want %d", usage.InputTokens, tt.expectedInputTokens)
|
||||
}
|
||||
if usage.CacheReadInputTokens != tt.expectedCacheReadTokens {
|
||||
t.Errorf("CacheReadInputTokens = %d, want %d", usage.CacheReadInputTokens, tt.expectedCacheReadTokens)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,22 @@ func (m *mockGatewayCacheForPlatform) DeleteSessionAccountID(ctx context.Context
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForPlatform) IncrModelCallCount(ctx context.Context, accountID int64, model string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForPlatform) GetModelLoadBatch(ctx context.Context, accountIDs []int64, model string) (map[int64]*ModelLoadInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForPlatform) FindGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, found bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForPlatform) SaveGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockGroupRepoForGateway struct {
|
||||
groups map[int64]*Group
|
||||
getByIDCalls int
|
||||
@@ -335,7 +351,7 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAntigravity)
|
||||
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAntigravity)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID)
|
||||
@@ -673,7 +689,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_ForcePlatform(t *tes
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "claude-3-5-sonnet-20241022", nil)
|
||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "claude-sonnet-4-5", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID)
|
||||
@@ -1017,10 +1033,16 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Antigravity平台-支持claude模型",
|
||||
name: "Antigravity平台-支持默认映射中的claude模型",
|
||||
account: &Account{Platform: PlatformAntigravity},
|
||||
model: "claude-sonnet-4-5",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Antigravity平台-不支持非默认映射中的claude模型",
|
||||
account: &Account{Platform: PlatformAntigravity},
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
expected: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Antigravity平台-支持gemini模型",
|
||||
@@ -1118,7 +1140,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID, "应选择优先级最高的账户(包含启用混合调度的antigravity)")
|
||||
@@ -1126,7 +1148,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
|
||||
t.Run("混合调度-路由优先选择路由账号", func(t *testing.T) {
|
||||
groupID := int64(30)
|
||||
requestedModel := "claude-3-5-sonnet-20241022"
|
||||
requestedModel := "claude-sonnet-4-5"
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: []Account{
|
||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||
@@ -1171,7 +1193,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
|
||||
t.Run("混合调度-路由粘性命中", func(t *testing.T) {
|
||||
groupID := int64(31)
|
||||
requestedModel := "claude-3-5-sonnet-20241022"
|
||||
requestedModel := "claude-sonnet-4-5"
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: []Account{
|
||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||
@@ -1323,7 +1345,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
"claude_sonnet": map[string]any{
|
||||
"claude-3-5-sonnet-20241022": map[string]any{
|
||||
"rate_limit_reset_at": resetAt.Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
@@ -1468,7 +1490,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "session-123", "claude-sonnet-4-5", nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(2), acc.ID, "应返回粘性会话绑定的启用mixed_scheduling的antigravity账户")
|
||||
@@ -1600,7 +1622,7 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||
cfg: testConfig(),
|
||||
}
|
||||
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acc)
|
||||
require.Equal(t, int64(1), acc.ID)
|
||||
@@ -1873,6 +1895,19 @@ func (m *mockConcurrencyCache) CleanupExpiredAccountSlots(ctx context.Context, a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockConcurrencyCache) GetUsersLoadBatch(ctx context.Context, users []UserWithConcurrency) (map[int64]*UserLoadInfo, error) {
|
||||
result := make(map[int64]*UserLoadInfo, len(users))
|
||||
for _, user := range users {
|
||||
result[user.ID] = &UserLoadInfo{
|
||||
UserID: user.ID,
|
||||
CurrentConcurrency: 0,
|
||||
WaitingCount: 0,
|
||||
LoadRate: 0,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TestGatewayService_SelectAccountWithLoadAwareness tests load-aware account selection
|
||||
func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -2750,7 +2785,7 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
|
||||
Concurrency: 5,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
"claude_sonnet": map[string]any{
|
||||
"claude-3-5-sonnet-20241022": map[string]any{
|
||||
"rate_limit_reset_at": now.Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
// ParsedRequest 保存网关请求的预解析结果
|
||||
@@ -19,13 +22,15 @@ import (
|
||||
// 2. 将解析结果 ParsedRequest 传递给 Service 层
|
||||
// 3. 避免重复 json.Unmarshal,减少 CPU 和内存开销
|
||||
type ParsedRequest struct {
|
||||
Body []byte // 原始请求体(保留用于转发)
|
||||
Model string // 请求的模型名称
|
||||
Stream bool // 是否为流式请求
|
||||
MetadataUserID string // metadata.user_id(用于会话亲和)
|
||||
System any // system 字段内容
|
||||
Messages []any // messages 数组
|
||||
HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入)
|
||||
Body []byte // 原始请求体(保留用于转发)
|
||||
Model string // 请求的模型名称
|
||||
Stream bool // 是否为流式请求
|
||||
MetadataUserID string // metadata.user_id(用于会话亲和)
|
||||
System any // system 字段内容
|
||||
Messages []any // messages 数组
|
||||
HasSystem bool // 是否包含 system 字段(包含 null 也视为显式传入)
|
||||
ThinkingEnabled bool // 是否开启 thinking(部分平台会影响最终模型名)
|
||||
MaxTokens int // max_tokens 值(用于探测请求拦截)
|
||||
}
|
||||
|
||||
// ParseGatewayRequest 解析网关请求体并返回结构化结果
|
||||
@@ -69,9 +74,62 @@ func ParseGatewayRequest(body []byte) (*ParsedRequest, error) {
|
||||
parsed.Messages = messages
|
||||
}
|
||||
|
||||
// thinking: {type: "enabled"}
|
||||
if rawThinking, ok := req["thinking"].(map[string]any); ok {
|
||||
if t, ok := rawThinking["type"].(string); ok && t == "enabled" {
|
||||
parsed.ThinkingEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// max_tokens
|
||||
if rawMaxTokens, exists := req["max_tokens"]; exists {
|
||||
if maxTokens, ok := parseIntegralNumber(rawMaxTokens); ok {
|
||||
parsed.MaxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// parseIntegralNumber 将 JSON 解码后的数字安全转换为 int。
|
||||
// 仅接受“整数值”的输入,小数/NaN/Inf/越界值都会返回 false。
|
||||
func parseIntegralNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
|
||||
return 0, false
|
||||
}
|
||||
if v > float64(math.MaxInt) || v < float64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case int8:
|
||||
return int(v), true
|
||||
case int16:
|
||||
return int(v), true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
if v > int64(math.MaxInt) || v < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
i64, err := v.Int64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if i64 > int64(math.MaxInt) || i64 < int64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(i64), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// FilterThinkingBlocks removes thinking blocks from request body
|
||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||
// This prevents 400 errors from invalid thinking block signatures
|
||||
@@ -466,7 +524,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
||||
// only keep thinking blocks with valid signatures
|
||||
if thinkingEnabled && role == "assistant" {
|
||||
signature, _ := blockMap["signature"].(string)
|
||||
if signature != "" && signature != "skip_thought_signature_validator" {
|
||||
if signature != "" && signature != antigravity.DummyThoughtSignature {
|
||||
newContent = append(newContent, block)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -17,6 +17,29 @@ func TestParseGatewayRequest(t *testing.T) {
|
||||
require.True(t, parsed.HasSystem)
|
||||
require.NotNil(t, parsed.System)
|
||||
require.Len(t, parsed.Messages, 1)
|
||||
require.False(t, parsed.ThinkingEnabled)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"enabled"},"messages":[{"content":"hi"}]}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-sonnet-4-5", parsed.Model)
|
||||
require.True(t, parsed.ThinkingEnabled)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, parsed.MaxTokens)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_MaxTokensNonIntegralIgnored(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1.5}`)
|
||||
parsed, err := ParseGatewayRequest(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, parsed.MaxTokens)
|
||||
}
|
||||
|
||||
func TestParseGatewayRequest_SystemNull(t *testing.T) {
|
||||
|
||||
@@ -12,10 +12,3 @@ func TestSanitizeOpenCodeText_RewritesCanonicalSentence(t *testing.T) {
|
||||
got := sanitizeSystemText(in)
|
||||
require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), got)
|
||||
}
|
||||
|
||||
func TestSanitizeToolDescription_DoesNotRewriteKeywords(t *testing.T) {
|
||||
in := "OpenCode and opencode are mentioned."
|
||||
got := sanitizeToolDescription(in)
|
||||
// We no longer rewrite tool descriptions; only redact obvious path leaks.
|
||||
require.Equal(t, in, got)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayService_isModelSupportedByAccount_AntigravityModelMapping(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
// 使用 model_mapping 作为白名单(通配符匹配)
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-*": "claude-sonnet-4-5",
|
||||
"gemini-3-*": "gemini-3-flash",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// claude-* 通配符匹配
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-sonnet-4-5"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-haiku-4-5"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-opus-4-6"))
|
||||
|
||||
// gemini-3-* 通配符匹配
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "gemini-3-flash"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "gemini-3-pro-high"))
|
||||
|
||||
// gemini-2.5-* 不匹配(不在 model_mapping 中)
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "gemini-2.5-flash"))
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "gemini-2.5-pro"))
|
||||
|
||||
// 其他平台模型不支持
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "gpt-4"))
|
||||
|
||||
// 空模型允许
|
||||
require.True(t, svc.isModelSupportedByAccount(account, ""))
|
||||
}
|
||||
|
||||
func TestGatewayService_isModelSupportedByAccount_AntigravityNoMapping(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
// 未配置 model_mapping 时,使用默认映射(domain.DefaultAntigravityModelMapping)
|
||||
// 只有默认映射中的模型才被支持
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{},
|
||||
}
|
||||
|
||||
// 默认映射中的模型应该被支持
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-sonnet-4-5"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "gemini-3-flash"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "gemini-2.5-pro"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-haiku-4-5"))
|
||||
|
||||
// 不在默认映射中的模型不被支持
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "claude-3-5-sonnet-20241022"))
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "claude-unknown-model"))
|
||||
|
||||
// 非 claude-/gemini- 前缀仍然不支持
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "gpt-4"))
|
||||
}
|
||||
|
||||
// TestGatewayService_isModelSupportedByAccountWithContext_ThinkingMode 测试 thinking 模式下的模型支持检查
|
||||
// 验证调度时使用映射后的最终模型名(包括 thinking 后缀)来检查 model_mapping 支持
|
||||
func TestGatewayService_isModelSupportedByAccountWithContext_ThinkingMode(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modelMapping map[string]any
|
||||
requestedModel string
|
||||
thinkingEnabled bool
|
||||
expected bool
|
||||
}{
|
||||
// 场景 1: 只配置 claude-sonnet-4-5-thinking,请求 claude-sonnet-4-5 + thinking=true
|
||||
// mapAntigravityModel 找不到 claude-sonnet-4-5 的映射 → 返回 false
|
||||
{
|
||||
name: "thinking_enabled_no_base_mapping_returns_false",
|
||||
modelMapping: map[string]any{
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: true,
|
||||
expected: false,
|
||||
},
|
||||
// 场景 2: 只配置 claude-sonnet-4-5-thinking,请求 claude-sonnet-4-5 + thinking=false
|
||||
// mapAntigravityModel 找不到 claude-sonnet-4-5 的映射 → 返回 false
|
||||
{
|
||||
name: "thinking_disabled_no_base_mapping_returns_false",
|
||||
modelMapping: map[string]any{
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: false,
|
||||
expected: false,
|
||||
},
|
||||
// 场景 3: 配置 claude-sonnet-4-5(非 thinking),请求 claude-sonnet-4-5 + thinking=true
|
||||
// 最终模型名 = claude-sonnet-4-5-thinking,不在 mapping 中,应该不匹配
|
||||
{
|
||||
name: "thinking_enabled_no_match_non_thinking_mapping",
|
||||
modelMapping: map[string]any{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: true,
|
||||
expected: false,
|
||||
},
|
||||
// 场景 4: 配置两种模型,请求 claude-sonnet-4-5 + thinking=true,应该匹配 thinking 版本
|
||||
{
|
||||
name: "both_models_thinking_enabled_matches_thinking",
|
||||
modelMapping: map[string]any{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: true,
|
||||
expected: true,
|
||||
},
|
||||
// 场景 5: 配置两种模型,请求 claude-sonnet-4-5 + thinking=false,应该匹配非 thinking 版本
|
||||
{
|
||||
name: "both_models_thinking_disabled_matches_non_thinking",
|
||||
modelMapping: map[string]any{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: false,
|
||||
expected: true,
|
||||
},
|
||||
// 场景 6: 通配符 claude-* 应该同时匹配 thinking 和非 thinking
|
||||
{
|
||||
name: "wildcard_matches_thinking",
|
||||
modelMapping: map[string]any{
|
||||
"claude-*": "claude-sonnet-4-5",
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
thinkingEnabled: true,
|
||||
expected: true, // claude-sonnet-4-5-thinking 匹配 claude-*
|
||||
},
|
||||
// 场景 7: 只配置 thinking 变体但没有基础模型映射 → 返回 false
|
||||
// mapAntigravityModel 找不到 claude-opus-4-6 的映射
|
||||
{
|
||||
name: "opus_thinking_no_base_mapping_returns_false",
|
||||
modelMapping: map[string]any{
|
||||
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
|
||||
},
|
||||
requestedModel: "claude-opus-4-6",
|
||||
thinkingEnabled: true,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": tt.modelMapping,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxkey.ThinkingEnabled, tt.thinkingEnabled)
|
||||
result := svc.isModelSupportedByAccountWithContext(ctx, account, tt.requestedModel)
|
||||
|
||||
require.Equal(t, tt.expected, result,
|
||||
"isModelSupportedByAccountWithContext(ctx[thinking=%v], account, %q) = %v, want %v",
|
||||
tt.thinkingEnabled, tt.requestedModel, result, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayService_isModelSupportedByAccount_CustomMappingNotInDefault 测试自定义模型映射中
|
||||
// 不在 DefaultAntigravityModelMapping 中的模型能通过调度
|
||||
func TestGatewayService_isModelSupportedByAccount_CustomMappingNotInDefault(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
// 自定义映射中包含不在默认映射中的模型
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"my-custom-model": "actual-upstream-model",
|
||||
"gpt-4o": "some-upstream-model",
|
||||
"llama-3-70b": "llama-3-70b-upstream",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 自定义模型应该通过(不在 DefaultAntigravityModelMapping 中也可以)
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "my-custom-model"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "gpt-4o"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "llama-3-70b"))
|
||||
require.True(t, svc.isModelSupportedByAccount(account, "claude-sonnet-4-5"))
|
||||
|
||||
// 不在自定义映射中的模型不通过
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "gpt-3.5-turbo"))
|
||||
require.False(t, svc.isModelSupportedByAccount(account, "unknown-model"))
|
||||
|
||||
// 空模型允许
|
||||
require.True(t, svc.isModelSupportedByAccount(account, ""))
|
||||
}
|
||||
|
||||
// TestGatewayService_isModelSupportedByAccountWithContext_CustomMappingThinking
|
||||
// 测试自定义映射 + thinking 模式的交互
|
||||
func TestGatewayService_isModelSupportedByAccountWithContext_CustomMappingThinking(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
|
||||
// 自定义映射同时配置基础模型和 thinking 变体
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
"my-custom-model": "upstream-model",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// thinking=true: claude-sonnet-4-5 → mapped=claude-sonnet-4-5 → +thinking → check IsModelSupported(claude-sonnet-4-5-thinking)=true
|
||||
ctx := context.WithValue(context.Background(), ctxkey.ThinkingEnabled, true)
|
||||
require.True(t, svc.isModelSupportedByAccountWithContext(ctx, account, "claude-sonnet-4-5"))
|
||||
|
||||
// thinking=false: claude-sonnet-4-5 → mapped=claude-sonnet-4-5 → check IsModelSupported(claude-sonnet-4-5)=true
|
||||
ctx = context.WithValue(context.Background(), ctxkey.ThinkingEnabled, false)
|
||||
require.True(t, svc.isModelSupportedByAccountWithContext(ctx, account, "claude-sonnet-4-5"))
|
||||
|
||||
// 自定义模型(非 claude)不受 thinking 后缀影响,mapped 成功即通过
|
||||
ctx = context.WithValue(context.Background(), ctxkey.ThinkingEnabled, true)
|
||||
require.True(t, svc.isModelSupportedByAccountWithContext(ctx, account, "my-custom-model"))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGatewayService_StreamingReusesScannerBufferAndStillParsesUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
rateLimitService: &RateLimitService{},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
// Minimal SSE event to trigger parseSSEUsage
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":3}}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":7}}\n\n"))
|
||||
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 3, result.usage.InputTokens)
|
||||
require.Equal(t, 7, result.usage.OutputTokens)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func (s *GeminiMessagesCompatService) tryStickySessionHit(
|
||||
|
||||
// 检查账号是否需要清理粘性会话
|
||||
// Check if sticky session should be cleared
|
||||
if shouldClearStickySession(account) {
|
||||
if shouldClearStickySession(account, requestedModel) {
|
||||
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), cacheKey)
|
||||
return nil
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func (s *GeminiMessagesCompatService) isAccountUsableForRequest(
|
||||
) bool {
|
||||
// 检查模型调度能力
|
||||
// Check model scheduling capability
|
||||
if !account.IsSchedulableForModel(requestedModel) {
|
||||
if !account.IsSchedulableForModelWithContext(ctx, requestedModel) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -362,7 +362,10 @@ func (s *GeminiMessagesCompatService) isBetterGeminiAccount(candidate, current *
|
||||
// isModelSupportedByAccount 根据账户平台检查模型支持
|
||||
func (s *GeminiMessagesCompatService) isModelSupportedByAccount(account *Account, requestedModel string) bool {
|
||||
if account.Platform == PlatformAntigravity {
|
||||
return IsAntigravityModelSupported(requestedModel)
|
||||
if strings.TrimSpace(requestedModel) == "" {
|
||||
return true
|
||||
}
|
||||
return mapAntigravityModel(account, requestedModel) != ""
|
||||
}
|
||||
return account.IsModelSupported(requestedModel)
|
||||
}
|
||||
@@ -1498,6 +1501,28 @@ func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, acc
|
||||
log.Printf("[Gemini] upstream error %d: %s", upstreamStatus, truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes))
|
||||
}
|
||||
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformGemini,
|
||||
upstreamStatus,
|
||||
body,
|
||||
http.StatusBadGateway,
|
||||
"upstream_error",
|
||||
"Upstream request failed",
|
||||
); matched {
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{"type": errType, "message": errMsg},
|
||||
})
|
||||
if upstreamMsg == "" {
|
||||
upstreamMsg = errMsg
|
||||
}
|
||||
if upstreamMsg == "" {
|
||||
return fmt.Errorf("upstream error: %d (passthrough rule matched)", upstreamStatus)
|
||||
}
|
||||
return fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", upstreamStatus, upstreamMsg)
|
||||
}
|
||||
|
||||
var statusCode int
|
||||
var errType, errMsg string
|
||||
|
||||
@@ -2636,7 +2661,9 @@ func ParseGeminiRateLimitResetTime(body []byte) *int64 {
|
||||
if meta, ok := dm["metadata"].(map[string]any); ok {
|
||||
if v, ok := meta["quotaResetDelay"].(string); ok {
|
||||
if dur, err := time.ParseDuration(v); err == nil {
|
||||
ts := time.Now().Unix() + int64(dur.Seconds())
|
||||
// Use ceil to avoid undercounting fractional seconds (e.g. 10.1s should not become 10s),
|
||||
// which can affect scheduling decisions around thresholds (like 10s).
|
||||
ts := time.Now().Unix() + int64(math.Ceil(dur.Seconds()))
|
||||
return &ts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,22 @@ func (m *mockGatewayCacheForGemini) DeleteSessionAccountID(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForGemini) IncrModelCallCount(ctx context.Context, accountID int64, model string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForGemini) GetModelLoadBatch(ctx context.Context, accountIDs []int64, model string) (map[int64]*ModelLoadInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForGemini) FindGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, found bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (m *mockGatewayCacheForGemini) SaveGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
|
||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -883,7 +899,7 @@ func TestGeminiMessagesCompatService_isModelSupportedByAccount(t *testing.T) {
|
||||
{
|
||||
name: "Antigravity平台-支持claude模型",
|
||||
account: &Account{Platform: PlatformAntigravity},
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
model: "claude-sonnet-4-5",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
@@ -892,6 +908,39 @@ func TestGeminiMessagesCompatService_isModelSupportedByAccount(t *testing.T) {
|
||||
model: "gpt-4",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Antigravity平台-空模型允许",
|
||||
account: &Account{Platform: PlatformAntigravity},
|
||||
model: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Antigravity平台-自定义映射-支持自定义模型",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"my-custom-model": "upstream-model",
|
||||
"gpt-4o": "some-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
model: "my-custom-model",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Antigravity平台-自定义映射-不在映射中的模型不支持",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"my-custom-model": "upstream-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
model: "claude-sonnet-4-5",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Gemini平台-无映射配置-支持所有模型",
|
||||
account: &Account{Platform: PlatformGemini},
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
// Gemini 会话 ID Fallback 相关常量
|
||||
const (
|
||||
// geminiSessionTTLSeconds Gemini 会话缓存 TTL(5 分钟)
|
||||
geminiSessionTTLSeconds = 300
|
||||
|
||||
// geminiSessionKeyPrefix Gemini 会话 Redis key 前缀
|
||||
geminiSessionKeyPrefix = "gemini:sess:"
|
||||
)
|
||||
|
||||
// GeminiSessionTTL 返回 Gemini 会话缓存 TTL
|
||||
func GeminiSessionTTL() time.Duration {
|
||||
return geminiSessionTTLSeconds * time.Second
|
||||
}
|
||||
|
||||
// shortHash 使用 XXHash64 + Base36 生成短 hash(16 字符)
|
||||
// XXHash64 比 SHA256 快约 10 倍,Base36 比 Hex 短约 20%
|
||||
func shortHash(data []byte) string {
|
||||
h := xxhash.Sum64(data)
|
||||
return strconv.FormatUint(h, 36)
|
||||
}
|
||||
|
||||
// BuildGeminiDigestChain 根据 Gemini 请求生成摘要链
|
||||
// 格式: s:<hash>-u:<hash>-m:<hash>-u:<hash>-...
|
||||
// s = systemInstruction, u = user, m = model
|
||||
func BuildGeminiDigestChain(req *antigravity.GeminiRequest) string {
|
||||
if req == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// 1. system instruction
|
||||
if req.SystemInstruction != nil && len(req.SystemInstruction.Parts) > 0 {
|
||||
partsData, _ := json.Marshal(req.SystemInstruction.Parts)
|
||||
parts = append(parts, "s:"+shortHash(partsData))
|
||||
}
|
||||
|
||||
// 2. contents
|
||||
for _, c := range req.Contents {
|
||||
prefix := "u" // user
|
||||
if c.Role == "model" {
|
||||
prefix = "m"
|
||||
}
|
||||
partsData, _ := json.Marshal(c.Parts)
|
||||
parts = append(parts, prefix+":"+shortHash(partsData))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// GenerateGeminiPrefixHash 生成前缀 hash(用于分区隔离)
|
||||
// 组合: userID + apiKeyID + ip + userAgent + platform + model
|
||||
// 返回 16 字符的 Base64 编码的 SHA256 前缀
|
||||
func GenerateGeminiPrefixHash(userID, apiKeyID int64, ip, userAgent, platform, model string) string {
|
||||
// 组合所有标识符
|
||||
combined := strconv.FormatInt(userID, 10) + ":" +
|
||||
strconv.FormatInt(apiKeyID, 10) + ":" +
|
||||
ip + ":" +
|
||||
userAgent + ":" +
|
||||
platform + ":" +
|
||||
model
|
||||
|
||||
hash := sha256.Sum256([]byte(combined))
|
||||
// 取前 12 字节,Base64 编码后正好 16 字符
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:12])
|
||||
}
|
||||
|
||||
// BuildGeminiSessionKey 构建 Gemini 会话 Redis key
|
||||
// 格式: gemini:sess:{groupID}:{prefixHash}:{digestChain}
|
||||
func BuildGeminiSessionKey(groupID int64, prefixHash, digestChain string) string {
|
||||
return geminiSessionKeyPrefix + strconv.FormatInt(groupID, 10) + ":" + prefixHash + ":" + digestChain
|
||||
}
|
||||
|
||||
// GenerateDigestChainPrefixes 生成摘要链的所有前缀(从长到短)
|
||||
// 用于 MGET 批量查询最长匹配
|
||||
func GenerateDigestChainPrefixes(chain string) []string {
|
||||
if chain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var prefixes []string
|
||||
c := chain
|
||||
|
||||
for c != "" {
|
||||
prefixes = append(prefixes, c)
|
||||
// 找到最后一个 "-" 的位置
|
||||
if i := strings.LastIndex(c, "-"); i > 0 {
|
||||
c = c[:i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// ParseGeminiSessionValue 解析 Gemini 会话缓存值
|
||||
// 格式: {uuid}:{accountID}
|
||||
func ParseGeminiSessionValue(value string) (uuid string, accountID int64, ok bool) {
|
||||
if value == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// 找到最后一个 ":" 的位置(因为 uuid 可能包含 ":")
|
||||
i := strings.LastIndex(value, ":")
|
||||
if i <= 0 || i >= len(value)-1 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
uuid = value[:i]
|
||||
accountID, err := strconv.ParseInt(value[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
return uuid, accountID, true
|
||||
}
|
||||
|
||||
// FormatGeminiSessionValue 格式化 Gemini 会话缓存值
|
||||
// 格式: {uuid}:{accountID}
|
||||
func FormatGeminiSessionValue(uuid string, accountID int64) string {
|
||||
return uuid + ":" + strconv.FormatInt(accountID, 10)
|
||||
}
|
||||
|
||||
// geminiDigestSessionKeyPrefix Gemini 摘要 fallback 会话 key 前缀
|
||||
const geminiDigestSessionKeyPrefix = "gemini:digest:"
|
||||
|
||||
// geminiTrieKeyPrefix Gemini Trie 会话 key 前缀
|
||||
const geminiTrieKeyPrefix = "gemini:trie:"
|
||||
|
||||
// BuildGeminiTrieKey 构建 Gemini Trie Redis key
|
||||
// 格式: gemini:trie:{groupID}:{prefixHash}
|
||||
func BuildGeminiTrieKey(groupID int64, prefixHash string) string {
|
||||
return geminiTrieKeyPrefix + strconv.FormatInt(groupID, 10) + ":" + prefixHash
|
||||
}
|
||||
|
||||
// GenerateGeminiDigestSessionKey 生成 Gemini 摘要 fallback 的 sessionKey
|
||||
// 组合 prefixHash 前 8 位 + uuid 前 8 位,确保不同会话产生不同的 sessionKey
|
||||
// 用于在 SelectAccountWithLoadAwareness 中保持粘性会话
|
||||
func GenerateGeminiDigestSessionKey(prefixHash, uuid string) string {
|
||||
prefix := prefixHash
|
||||
if len(prefixHash) >= 8 {
|
||||
prefix = prefixHash[:8]
|
||||
}
|
||||
uuidPart := uuid
|
||||
if len(uuid) >= 8 {
|
||||
uuidPart = uuid[:8]
|
||||
}
|
||||
return geminiDigestSessionKeyPrefix + prefix + ":" + uuidPart
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
// mockGeminiSessionCache 模拟 Redis 缓存
|
||||
type mockGeminiSessionCache struct {
|
||||
sessions map[string]string // key -> value
|
||||
}
|
||||
|
||||
func newMockGeminiSessionCache() *mockGeminiSessionCache {
|
||||
return &mockGeminiSessionCache{sessions: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (m *mockGeminiSessionCache) Save(groupID int64, prefixHash, digestChain, uuid string, accountID int64) {
|
||||
key := BuildGeminiSessionKey(groupID, prefixHash, digestChain)
|
||||
value := FormatGeminiSessionValue(uuid, accountID)
|
||||
m.sessions[key] = value
|
||||
}
|
||||
|
||||
func (m *mockGeminiSessionCache) Find(groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, found bool) {
|
||||
prefixes := GenerateDigestChainPrefixes(digestChain)
|
||||
for _, p := range prefixes {
|
||||
key := BuildGeminiSessionKey(groupID, prefixHash, p)
|
||||
if val, ok := m.sessions[key]; ok {
|
||||
return ParseGeminiSessionValue(val)
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// TestGeminiSessionContinuousConversation 测试连续会话的摘要链匹配
|
||||
func TestGeminiSessionContinuousConversation(t *testing.T) {
|
||||
cache := newMockGeminiSessionCache()
|
||||
groupID := int64(1)
|
||||
prefixHash := "test_prefix_hash"
|
||||
sessionUUID := "session-uuid-12345"
|
||||
accountID := int64(100)
|
||||
|
||||
// 模拟第一轮对话
|
||||
req1 := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "You are a helpful assistant"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Hello, what's your name?"}}},
|
||||
},
|
||||
}
|
||||
chain1 := BuildGeminiDigestChain(req1)
|
||||
t.Logf("Round 1 chain: %s", chain1)
|
||||
|
||||
// 第一轮:没有找到会话,创建新会话
|
||||
_, _, found := cache.Find(groupID, prefixHash, chain1)
|
||||
if found {
|
||||
t.Error("Round 1: should not find existing session")
|
||||
}
|
||||
|
||||
// 保存第一轮会话
|
||||
cache.Save(groupID, prefixHash, chain1, sessionUUID, accountID)
|
||||
|
||||
// 模拟第二轮对话(用户继续对话)
|
||||
req2 := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "You are a helpful assistant"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Hello, what's your name?"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "I'm Claude, nice to meet you!"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "What can you do?"}}},
|
||||
},
|
||||
}
|
||||
chain2 := BuildGeminiDigestChain(req2)
|
||||
t.Logf("Round 2 chain: %s", chain2)
|
||||
|
||||
// 第二轮:应该能找到会话(通过前缀匹配)
|
||||
foundUUID, foundAccID, found := cache.Find(groupID, prefixHash, chain2)
|
||||
if !found {
|
||||
t.Error("Round 2: should find session via prefix matching")
|
||||
}
|
||||
if foundUUID != sessionUUID {
|
||||
t.Errorf("Round 2: expected UUID %s, got %s", sessionUUID, foundUUID)
|
||||
}
|
||||
if foundAccID != accountID {
|
||||
t.Errorf("Round 2: expected accountID %d, got %d", accountID, foundAccID)
|
||||
}
|
||||
|
||||
// 保存第二轮会话
|
||||
cache.Save(groupID, prefixHash, chain2, sessionUUID, accountID)
|
||||
|
||||
// 模拟第三轮对话
|
||||
req3 := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "You are a helpful assistant"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Hello, what's your name?"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "I'm Claude, nice to meet you!"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "What can you do?"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "I can help with coding, writing, and more!"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Great, help me write some Go code"}}},
|
||||
},
|
||||
}
|
||||
chain3 := BuildGeminiDigestChain(req3)
|
||||
t.Logf("Round 3 chain: %s", chain3)
|
||||
|
||||
// 第三轮:应该能找到会话(通过第二轮的前缀匹配)
|
||||
foundUUID, foundAccID, found = cache.Find(groupID, prefixHash, chain3)
|
||||
if !found {
|
||||
t.Error("Round 3: should find session via prefix matching")
|
||||
}
|
||||
if foundUUID != sessionUUID {
|
||||
t.Errorf("Round 3: expected UUID %s, got %s", sessionUUID, foundUUID)
|
||||
}
|
||||
if foundAccID != accountID {
|
||||
t.Errorf("Round 3: expected accountID %d, got %d", accountID, foundAccID)
|
||||
}
|
||||
|
||||
t.Log("✓ Continuous conversation session matching works correctly!")
|
||||
}
|
||||
|
||||
// TestGeminiSessionDifferentConversations 测试不同会话不会错误匹配
|
||||
func TestGeminiSessionDifferentConversations(t *testing.T) {
|
||||
cache := newMockGeminiSessionCache()
|
||||
groupID := int64(1)
|
||||
prefixHash := "test_prefix_hash"
|
||||
|
||||
// 第一个会话
|
||||
req1 := &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Tell me about Go programming"}}},
|
||||
},
|
||||
}
|
||||
chain1 := BuildGeminiDigestChain(req1)
|
||||
cache.Save(groupID, prefixHash, chain1, "session-1", 100)
|
||||
|
||||
// 第二个完全不同的会话
|
||||
req2 := &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "What's the weather today?"}}},
|
||||
},
|
||||
}
|
||||
chain2 := BuildGeminiDigestChain(req2)
|
||||
|
||||
// 不同会话不应该匹配
|
||||
_, _, found := cache.Find(groupID, prefixHash, chain2)
|
||||
if found {
|
||||
t.Error("Different conversations should not match")
|
||||
}
|
||||
|
||||
t.Log("✓ Different conversations are correctly isolated!")
|
||||
}
|
||||
|
||||
// TestGeminiSessionPrefixMatchingOrder 测试前缀匹配的优先级(最长匹配优先)
|
||||
func TestGeminiSessionPrefixMatchingOrder(t *testing.T) {
|
||||
cache := newMockGeminiSessionCache()
|
||||
groupID := int64(1)
|
||||
prefixHash := "test_prefix_hash"
|
||||
|
||||
// 创建一个三轮对话
|
||||
req := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "System prompt"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Q1"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "A1"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "Q2"}}},
|
||||
},
|
||||
}
|
||||
fullChain := BuildGeminiDigestChain(req)
|
||||
prefixes := GenerateDigestChainPrefixes(fullChain)
|
||||
|
||||
t.Logf("Full chain: %s", fullChain)
|
||||
t.Logf("Prefixes (longest first): %v", prefixes)
|
||||
|
||||
// 验证前缀生成顺序(从长到短)
|
||||
if len(prefixes) != 4 {
|
||||
t.Errorf("Expected 4 prefixes, got %d", len(prefixes))
|
||||
}
|
||||
|
||||
// 保存不同轮次的会话到不同账号
|
||||
// 第一轮(最短前缀)-> 账号 1
|
||||
cache.Save(groupID, prefixHash, prefixes[3], "session-round1", 1)
|
||||
// 第二轮 -> 账号 2
|
||||
cache.Save(groupID, prefixHash, prefixes[2], "session-round2", 2)
|
||||
// 第三轮(最长前缀,完整链)-> 账号 3
|
||||
cache.Save(groupID, prefixHash, prefixes[0], "session-round3", 3)
|
||||
|
||||
// 查找应该返回最长匹配(账号 3)
|
||||
_, accID, found := cache.Find(groupID, prefixHash, fullChain)
|
||||
if !found {
|
||||
t.Error("Should find session")
|
||||
}
|
||||
if accID != 3 {
|
||||
t.Errorf("Should match longest prefix (account 3), got account %d", accID)
|
||||
}
|
||||
|
||||
t.Log("✓ Longest prefix matching works correctly!")
|
||||
}
|
||||
|
||||
// 确保 context 包被使用(避免未使用的导入警告)
|
||||
var _ = context.Background
|
||||
@@ -0,0 +1,481 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
func TestShortHash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
}{
|
||||
{"empty", []byte{}},
|
||||
{"simple", []byte("hello world")},
|
||||
{"json", []byte(`{"role":"user","parts":[{"text":"hello"}]}`)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := shortHash(tt.input)
|
||||
// Base36 编码的 uint64 最长 13 个字符
|
||||
if len(result) > 13 {
|
||||
t.Errorf("shortHash result too long: %d characters", len(result))
|
||||
}
|
||||
// 相同输入应该产生相同输出
|
||||
result2 := shortHash(tt.input)
|
||||
if result != result2 {
|
||||
t.Errorf("shortHash not deterministic: %s vs %s", result, result2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGeminiDigestChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *antigravity.GeminiRequest
|
||||
wantLen int // 预期的分段数量
|
||||
hasEmpty bool // 是否应该是空字符串
|
||||
}{
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
hasEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "empty contents",
|
||||
req: &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{},
|
||||
},
|
||||
hasEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "single user message",
|
||||
req: &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
},
|
||||
},
|
||||
wantLen: 1, // u:<hash>
|
||||
},
|
||||
{
|
||||
name: "user and model messages",
|
||||
req: &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "hi there"}}},
|
||||
},
|
||||
},
|
||||
wantLen: 2, // u:<hash>-m:<hash>
|
||||
},
|
||||
{
|
||||
name: "with system instruction",
|
||||
req: &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Role: "user",
|
||||
Parts: []antigravity.GeminiPart{{Text: "You are a helpful assistant"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
},
|
||||
},
|
||||
wantLen: 2, // s:<hash>-u:<hash>
|
||||
},
|
||||
{
|
||||
name: "conversation with system",
|
||||
req: &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Role: "user",
|
||||
Parts: []antigravity.GeminiPart{{Text: "System prompt"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "hi"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "how are you?"}}},
|
||||
},
|
||||
},
|
||||
wantLen: 4, // s:<hash>-u:<hash>-m:<hash>-u:<hash>
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := BuildGeminiDigestChain(tt.req)
|
||||
|
||||
if tt.hasEmpty {
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got: %s", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 检查分段数量
|
||||
parts := splitChain(result)
|
||||
if len(parts) != tt.wantLen {
|
||||
t.Errorf("expected %d parts, got %d: %s", tt.wantLen, len(parts), result)
|
||||
}
|
||||
|
||||
// 验证每个分段的格式
|
||||
for _, part := range parts {
|
||||
if len(part) < 3 || part[1] != ':' {
|
||||
t.Errorf("invalid part format: %s", part)
|
||||
}
|
||||
prefix := part[0]
|
||||
if prefix != 's' && prefix != 'u' && prefix != 'm' {
|
||||
t.Errorf("invalid prefix: %c", prefix)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGeminiPrefixHash(t *testing.T) {
|
||||
hash1 := GenerateGeminiPrefixHash(1, 100, "192.168.1.1", "Mozilla/5.0", "antigravity", "gemini-2.5-pro")
|
||||
hash2 := GenerateGeminiPrefixHash(1, 100, "192.168.1.1", "Mozilla/5.0", "antigravity", "gemini-2.5-pro")
|
||||
hash3 := GenerateGeminiPrefixHash(2, 100, "192.168.1.1", "Mozilla/5.0", "antigravity", "gemini-2.5-pro")
|
||||
|
||||
// 相同输入应该产生相同输出
|
||||
if hash1 != hash2 {
|
||||
t.Errorf("GenerateGeminiPrefixHash not deterministic: %s vs %s", hash1, hash2)
|
||||
}
|
||||
|
||||
// 不同输入应该产生不同输出
|
||||
if hash1 == hash3 {
|
||||
t.Errorf("GenerateGeminiPrefixHash collision for different inputs")
|
||||
}
|
||||
|
||||
// Base64 URL 编码的 12 字节正好是 16 字符
|
||||
if len(hash1) != 16 {
|
||||
t.Errorf("expected 16 characters, got %d: %s", len(hash1), hash1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDigestChainPrefixes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chain string
|
||||
want []string
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
chain: "",
|
||||
wantLen: 0,
|
||||
},
|
||||
{
|
||||
name: "single part",
|
||||
chain: "u:abc123",
|
||||
want: []string{"u:abc123"},
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "two parts",
|
||||
chain: "s:xyz-u:abc",
|
||||
want: []string{"s:xyz-u:abc", "s:xyz"},
|
||||
wantLen: 2,
|
||||
},
|
||||
{
|
||||
name: "four parts",
|
||||
chain: "s:a-u:b-m:c-u:d",
|
||||
want: []string{"s:a-u:b-m:c-u:d", "s:a-u:b-m:c", "s:a-u:b", "s:a"},
|
||||
wantLen: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GenerateDigestChainPrefixes(tt.chain)
|
||||
|
||||
if len(result) != tt.wantLen {
|
||||
t.Errorf("expected %d prefixes, got %d: %v", tt.wantLen, len(result), result)
|
||||
}
|
||||
|
||||
if tt.want != nil {
|
||||
for i, want := range tt.want {
|
||||
if i >= len(result) {
|
||||
t.Errorf("missing prefix at index %d", i)
|
||||
continue
|
||||
}
|
||||
if result[i] != want {
|
||||
t.Errorf("prefix[%d]: expected %s, got %s", i, want, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGeminiSessionValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantUUID string
|
||||
wantAccID int64
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
value: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no colon",
|
||||
value: "abc123",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
value: "uuid-1234:100",
|
||||
wantUUID: "uuid-1234",
|
||||
wantAccID: 100,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "uuid with colon",
|
||||
value: "a:b:c:123",
|
||||
wantUUID: "a:b:c",
|
||||
wantAccID: 123,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "invalid account id",
|
||||
value: "uuid:abc",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
uuid, accID, ok := ParseGeminiSessionValue(tt.value)
|
||||
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok: expected %v, got %v", tt.wantOK, ok)
|
||||
}
|
||||
|
||||
if tt.wantOK {
|
||||
if uuid != tt.wantUUID {
|
||||
t.Errorf("uuid: expected %s, got %s", tt.wantUUID, uuid)
|
||||
}
|
||||
if accID != tt.wantAccID {
|
||||
t.Errorf("accountID: expected %d, got %d", tt.wantAccID, accID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatGeminiSessionValue(t *testing.T) {
|
||||
result := FormatGeminiSessionValue("test-uuid", 123)
|
||||
expected := "test-uuid:123"
|
||||
if result != expected {
|
||||
t.Errorf("expected %s, got %s", expected, result)
|
||||
}
|
||||
|
||||
// 验证往返一致性
|
||||
uuid, accID, ok := ParseGeminiSessionValue(result)
|
||||
if !ok {
|
||||
t.Error("ParseGeminiSessionValue failed on formatted value")
|
||||
}
|
||||
if uuid != "test-uuid" || accID != 123 {
|
||||
t.Errorf("round-trip failed: uuid=%s, accID=%d", uuid, accID)
|
||||
}
|
||||
}
|
||||
|
||||
// splitChain 辅助函数:按 "-" 分割摘要链
|
||||
func splitChain(chain string) []string {
|
||||
if chain == "" {
|
||||
return nil
|
||||
}
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(chain); i++ {
|
||||
if chain[i] == '-' {
|
||||
parts = append(parts, chain[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(chain) {
|
||||
parts = append(parts, chain[start:])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func TestDigestChainDifferentSysInstruction(t *testing.T) {
|
||||
req1 := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "SYS_ORIGINAL"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
},
|
||||
}
|
||||
|
||||
req2 := &antigravity.GeminiRequest{
|
||||
SystemInstruction: &antigravity.GeminiContent{
|
||||
Parts: []antigravity.GeminiPart{{Text: "SYS_MODIFIED"}},
|
||||
},
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
},
|
||||
}
|
||||
|
||||
chain1 := BuildGeminiDigestChain(req1)
|
||||
chain2 := BuildGeminiDigestChain(req2)
|
||||
|
||||
t.Logf("Chain1: %s", chain1)
|
||||
t.Logf("Chain2: %s", chain2)
|
||||
|
||||
if chain1 == chain2 {
|
||||
t.Error("Different systemInstruction should produce different chains")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestChainTamperedMiddleContent(t *testing.T) {
|
||||
req1 := &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "ORIGINAL_REPLY"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "next"}}},
|
||||
},
|
||||
}
|
||||
|
||||
req2 := &antigravity.GeminiRequest{
|
||||
Contents: []antigravity.GeminiContent{
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "hello"}}},
|
||||
{Role: "model", Parts: []antigravity.GeminiPart{{Text: "TAMPERED_REPLY"}}},
|
||||
{Role: "user", Parts: []antigravity.GeminiPart{{Text: "next"}}},
|
||||
},
|
||||
}
|
||||
|
||||
chain1 := BuildGeminiDigestChain(req1)
|
||||
chain2 := BuildGeminiDigestChain(req2)
|
||||
|
||||
t.Logf("Chain1: %s", chain1)
|
||||
t.Logf("Chain2: %s", chain2)
|
||||
|
||||
if chain1 == chain2 {
|
||||
t.Error("Tampered middle content should produce different chains")
|
||||
}
|
||||
|
||||
// 验证第一个 user 的 hash 相同
|
||||
parts1 := splitChain(chain1)
|
||||
parts2 := splitChain(chain2)
|
||||
|
||||
if parts1[0] != parts2[0] {
|
||||
t.Error("First user message hash should be the same")
|
||||
}
|
||||
if parts1[1] == parts2[1] {
|
||||
t.Error("Model reply hash should be different")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateGeminiDigestSessionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefixHash string
|
||||
uuid string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal 16 char hash with uuid",
|
||||
prefixHash: "abcdefgh12345678",
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
want: "gemini:digest:abcdefgh:550e8400",
|
||||
},
|
||||
{
|
||||
name: "exactly 8 chars prefix and uuid",
|
||||
prefixHash: "12345678",
|
||||
uuid: "abcdefgh",
|
||||
want: "gemini:digest:12345678:abcdefgh",
|
||||
},
|
||||
{
|
||||
name: "short hash and short uuid (less than 8)",
|
||||
prefixHash: "abc",
|
||||
uuid: "xyz",
|
||||
want: "gemini:digest:abc:xyz",
|
||||
},
|
||||
{
|
||||
name: "empty hash and uuid",
|
||||
prefixHash: "",
|
||||
uuid: "",
|
||||
want: "gemini:digest::",
|
||||
},
|
||||
{
|
||||
name: "normal prefix with short uuid",
|
||||
prefixHash: "abcdefgh12345678",
|
||||
uuid: "short",
|
||||
want: "gemini:digest:abcdefgh:short",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateGeminiDigestSessionKey(tt.prefixHash, tt.uuid)
|
||||
if got != tt.want {
|
||||
t.Errorf("GenerateGeminiDigestSessionKey(%q, %q) = %q, want %q", tt.prefixHash, tt.uuid, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 验证确定性:相同输入产生相同输出
|
||||
t.Run("deterministic", func(t *testing.T) {
|
||||
hash := "testprefix123456"
|
||||
uuid := "test-uuid-12345"
|
||||
result1 := GenerateGeminiDigestSessionKey(hash, uuid)
|
||||
result2 := GenerateGeminiDigestSessionKey(hash, uuid)
|
||||
if result1 != result2 {
|
||||
t.Errorf("GenerateGeminiDigestSessionKey not deterministic: %s vs %s", result1, result2)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证不同 uuid 产生不同 sessionKey(负载均衡核心逻辑)
|
||||
t.Run("different uuid different key", func(t *testing.T) {
|
||||
hash := "sameprefix123456"
|
||||
uuid1 := "uuid0001-session-a"
|
||||
uuid2 := "uuid0002-session-b"
|
||||
result1 := GenerateGeminiDigestSessionKey(hash, uuid1)
|
||||
result2 := GenerateGeminiDigestSessionKey(hash, uuid2)
|
||||
if result1 == result2 {
|
||||
t.Errorf("Different UUIDs should produce different session keys: %s vs %s", result1, result2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildGeminiTrieKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groupID int64
|
||||
prefixHash string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal",
|
||||
groupID: 123,
|
||||
prefixHash: "abcdef12",
|
||||
want: "gemini:trie:123:abcdef12",
|
||||
},
|
||||
{
|
||||
name: "zero group",
|
||||
groupID: 0,
|
||||
prefixHash: "xyz",
|
||||
want: "gemini:trie:0:xyz",
|
||||
},
|
||||
{
|
||||
name: "empty prefix",
|
||||
groupID: 1,
|
||||
prefixHash: "",
|
||||
want: "gemini:trie:1:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := BuildGeminiTrieKey(tt.groupID, tt.prefixHash)
|
||||
if got != tt.want {
|
||||
t.Errorf("BuildGeminiTrieKey(%d, %q) = %q, want %q", tt.groupID, tt.prefixHash, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
)
|
||||
|
||||
const modelRateLimitsKey = "model_rate_limits"
|
||||
const modelRateLimitScopeClaudeSonnet = "claude_sonnet"
|
||||
|
||||
func resolveModelRateLimitScope(requestedModel string) (string, bool) {
|
||||
model := strings.ToLower(strings.TrimSpace(requestedModel))
|
||||
if model == "" {
|
||||
return "", false
|
||||
}
|
||||
model = strings.TrimPrefix(model, "models/")
|
||||
if strings.Contains(model, "sonnet") {
|
||||
return modelRateLimitScopeClaudeSonnet, true
|
||||
}
|
||||
return "", false
|
||||
// isRateLimitActiveForKey 检查指定 key 的限流是否生效
|
||||
func (a *Account) isRateLimitActiveForKey(key string) bool {
|
||||
resetAt := a.modelRateLimitResetAt(key)
|
||||
return resetAt != nil && time.Now().Before(*resetAt)
|
||||
}
|
||||
|
||||
func (a *Account) isModelRateLimited(requestedModel string) bool {
|
||||
scope, ok := resolveModelRateLimitScope(requestedModel)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
resetAt := a.modelRateLimitResetAt(scope)
|
||||
// getRateLimitRemainingForKey 获取指定 key 的限流剩余时间,0 表示未限流或已过期
|
||||
func (a *Account) getRateLimitRemainingForKey(key string) time.Duration {
|
||||
resetAt := a.modelRateLimitResetAt(key)
|
||||
if resetAt == nil {
|
||||
return 0
|
||||
}
|
||||
remaining := time.Until(*resetAt)
|
||||
if remaining > 0 {
|
||||
return remaining
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *Account) isModelRateLimitedWithContext(ctx context.Context, requestedModel string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Before(*resetAt)
|
||||
|
||||
modelKey := a.GetMappedModel(requestedModel)
|
||||
if a.Platform == PlatformAntigravity {
|
||||
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
||||
}
|
||||
modelKey = strings.TrimSpace(modelKey)
|
||||
if modelKey == "" {
|
||||
return false
|
||||
}
|
||||
return a.isRateLimitActiveForKey(modelKey)
|
||||
}
|
||||
|
||||
// GetModelRateLimitRemainingTime 获取模型限流剩余时间
|
||||
// 返回 0 表示未限流或已过期
|
||||
func (a *Account) GetModelRateLimitRemainingTime(requestedModel string) time.Duration {
|
||||
return a.GetModelRateLimitRemainingTimeWithContext(context.Background(), requestedModel)
|
||||
}
|
||||
|
||||
func (a *Account) GetModelRateLimitRemainingTimeWithContext(ctx context.Context, requestedModel string) time.Duration {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
modelKey := a.GetMappedModel(requestedModel)
|
||||
if a.Platform == PlatformAntigravity {
|
||||
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
||||
}
|
||||
modelKey = strings.TrimSpace(modelKey)
|
||||
if modelKey == "" {
|
||||
return 0
|
||||
}
|
||||
return a.getRateLimitRemainingForKey(modelKey)
|
||||
}
|
||||
|
||||
func resolveFinalAntigravityModelKey(ctx context.Context, account *Account, requestedModel string) string {
|
||||
modelKey := mapAntigravityModel(account, requestedModel)
|
||||
if modelKey == "" {
|
||||
return ""
|
||||
}
|
||||
// thinking 会影响 Antigravity 最终模型名(例如 claude-sonnet-4-5 -> claude-sonnet-4-5-thinking)
|
||||
if enabled, ok := ctx.Value(ctxkey.ThinkingEnabled).(bool); ok {
|
||||
modelKey = applyThinkingModelSuffix(modelKey, enabled)
|
||||
}
|
||||
return modelKey
|
||||
}
|
||||
|
||||
func (a *Account) modelRateLimitResetAt(scope string) *time.Time {
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
)
|
||||
|
||||
func TestIsModelRateLimited(t *testing.T) {
|
||||
now := time.Now()
|
||||
future := now.Add(10 * time.Minute).Format(time.RFC3339)
|
||||
past := now.Add(-10 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
requestedModel string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "official model ID hit - claude-sonnet-4-5",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "official model ID hit via mapping - request claude-3-5-sonnet, mapped to claude-sonnet-4-5",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-3-5-sonnet",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no rate limit - expired",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": past,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no rate limit - no matching key",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-3-flash": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no rate limit - unsupported model",
|
||||
account: &Account{},
|
||||
requestedModel: "gpt-4",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no rate limit - empty model",
|
||||
account: &Account{},
|
||||
requestedModel: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "gemini model hit",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-3-pro-high": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "gemini-3-pro-high",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "antigravity platform - gemini-3-pro-preview mapped to gemini-3-pro-high",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-3-pro-high": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "gemini-3-pro-preview",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-antigravity platform - gemini-3-pro-preview NOT mapped",
|
||||
account: &Account{
|
||||
Platform: PlatformGemini,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"gemini-3-pro-high": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "gemini-3-pro-preview",
|
||||
expected: false, // gemini 平台不走 antigravity 映射
|
||||
},
|
||||
{
|
||||
name: "antigravity platform - claude-opus-4-5-thinking mapped to opus-4-6-thinking",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-opus-4-6-thinking": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-opus-4-5-thinking",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no scope fallback - claude_sonnet should not match",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude_sonnet": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-3-5-sonnet-20241022",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.account.isModelRateLimitedWithContext(context.Background(), tt.requestedModel)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isModelRateLimited(%q) = %v, want %v", tt.requestedModel, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsModelRateLimited_Antigravity_ThinkingAffectsModelKey(t *testing.T) {
|
||||
now := time.Now()
|
||||
future := now.Add(10 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
account := &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5-thinking": map[string]any{
|
||||
"rate_limit_reset_at": future,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxkey.ThinkingEnabled, true)
|
||||
if !account.isModelRateLimitedWithContext(ctx, "claude-sonnet-4-5") {
|
||||
t.Errorf("expected model to be rate limited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelRateLimitRemainingTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
future10m := now.Add(10 * time.Minute).Format(time.RFC3339)
|
||||
future5m := now.Add(5 * time.Minute).Format(time.RFC3339)
|
||||
past := now.Add(-10 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
requestedModel string
|
||||
minExpected time.Duration
|
||||
maxExpected time.Duration
|
||||
}{
|
||||
{
|
||||
name: "nil account",
|
||||
account: nil,
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "model rate limited - direct hit",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future10m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 9 * time.Minute,
|
||||
maxExpected: 11 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "model rate limited - via mapping",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"claude-3-5-sonnet": "claude-sonnet-4-5",
|
||||
},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future5m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-3-5-sonnet",
|
||||
minExpected: 4 * time.Minute,
|
||||
maxExpected: 6 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "expired rate limit",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": past,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "no rate limit data",
|
||||
account: &Account{},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "no scope fallback",
|
||||
account: &Account{
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude_sonnet": map[string]any{
|
||||
"rate_limit_reset_at": future5m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-3-5-sonnet-20241022",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "antigravity platform - claude-opus-4-5-thinking mapped to opus-4-6-thinking",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-opus-4-6-thinking": map[string]any{
|
||||
"rate_limit_reset_at": future5m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-opus-4-5-thinking",
|
||||
minExpected: 4 * time.Minute,
|
||||
maxExpected: 6 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.account.GetModelRateLimitRemainingTimeWithContext(context.Background(), tt.requestedModel)
|
||||
if result < tt.minExpected || result > tt.maxExpected {
|
||||
t.Errorf("GetModelRateLimitRemainingTime() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuotaScopeRateLimitRemainingTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
future10m := now.Add(10 * time.Minute).Format(time.RFC3339)
|
||||
past := now.Add(-10 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
requestedModel string
|
||||
minExpected time.Duration
|
||||
maxExpected time.Duration
|
||||
}{
|
||||
{
|
||||
name: "nil account",
|
||||
account: nil,
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "non-antigravity platform",
|
||||
account: &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Extra: map[string]any{
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": future10m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "claude scope rate limited",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": future10m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 9 * time.Minute,
|
||||
maxExpected: 11 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "gemini_text scope rate limited",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"gemini_text": map[string]any{
|
||||
"rate_limit_reset_at": future10m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "gemini-3-flash",
|
||||
minExpected: 9 * time.Minute,
|
||||
maxExpected: 11 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "expired scope rate limit",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": past,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "unsupported model",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
},
|
||||
requestedModel: "gpt-4",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.account.GetQuotaScopeRateLimitRemainingTime(tt.requestedModel)
|
||||
if result < tt.minExpected || result > tt.maxExpected {
|
||||
t.Errorf("GetQuotaScopeRateLimitRemainingTime() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRateLimitRemainingTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
future15m := now.Add(15 * time.Minute).Format(time.RFC3339)
|
||||
future5m := now.Add(5 * time.Minute).Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
requestedModel string
|
||||
minExpected time.Duration
|
||||
maxExpected time.Duration
|
||||
}{
|
||||
{
|
||||
name: "nil account",
|
||||
account: nil,
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
{
|
||||
name: "model remaining > scope remaining - returns model",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future15m, // 15 分钟
|
||||
},
|
||||
},
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": future5m, // 5 分钟
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 14 * time.Minute, // 应返回较大的 15 分钟
|
||||
maxExpected: 16 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "scope remaining > model remaining - returns scope",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future5m, // 5 分钟
|
||||
},
|
||||
},
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": future15m, // 15 分钟
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 14 * time.Minute, // 应返回较大的 15 分钟
|
||||
maxExpected: 16 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "only model rate limited",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
modelRateLimitsKey: map[string]any{
|
||||
"claude-sonnet-4-5": map[string]any{
|
||||
"rate_limit_reset_at": future5m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 4 * time.Minute,
|
||||
maxExpected: 6 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "only scope rate limited",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: map[string]any{
|
||||
antigravityQuotaScopesKey: map[string]any{
|
||||
"claude": map[string]any{
|
||||
"rate_limit_reset_at": future5m,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 4 * time.Minute,
|
||||
maxExpected: 6 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "neither rate limited",
|
||||
account: &Account{
|
||||
Platform: PlatformAntigravity,
|
||||
},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
minExpected: 0,
|
||||
maxExpected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.account.GetRateLimitRemainingTimeWithContext(context.Background(), tt.requestedModel)
|
||||
if result < tt.minExpected || result > tt.maxExpected {
|
||||
t.Errorf("GetRateLimitRemainingTime() = %v, want between %v and %v", result, tt.minExpected, tt.maxExpected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
opencodeCodexHeaderURL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/opencode/src/session/prompt/codex_header.txt"
|
||||
codexCacheTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
//go:embed prompts/codex_cli_instructions.md
|
||||
@@ -77,12 +65,6 @@ type codexTransformResult struct {
|
||||
PromptCacheKey string
|
||||
}
|
||||
|
||||
type opencodeCacheMetadata struct {
|
||||
ETag string `json:"etag"`
|
||||
LastFetch string `json:"lastFetch,omitempty"`
|
||||
LastChecked int64 `json:"lastChecked"`
|
||||
}
|
||||
|
||||
func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool) codexTransformResult {
|
||||
result := codexTransformResult{}
|
||||
// 工具续链需求会影响存储策略与 input 过滤逻辑。
|
||||
@@ -216,54 +198,9 @@ func getNormalizedCodexModel(modelID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getOpenCodeCachedPrompt(url, cacheFileName, metaFileName string) string {
|
||||
cacheDir := codexCachePath("")
|
||||
if cacheDir == "" {
|
||||
return ""
|
||||
}
|
||||
cacheFile := filepath.Join(cacheDir, cacheFileName)
|
||||
metaFile := filepath.Join(cacheDir, metaFileName)
|
||||
|
||||
var cachedContent string
|
||||
if content, ok := readFile(cacheFile); ok {
|
||||
cachedContent = content
|
||||
}
|
||||
|
||||
var meta opencodeCacheMetadata
|
||||
if loadJSON(metaFile, &meta) && meta.LastChecked > 0 && cachedContent != "" {
|
||||
if time.Since(time.UnixMilli(meta.LastChecked)) < codexCacheTTL {
|
||||
return cachedContent
|
||||
}
|
||||
}
|
||||
|
||||
content, etag, status, err := fetchWithETag(url, meta.ETag)
|
||||
if err == nil && status == http.StatusNotModified && cachedContent != "" {
|
||||
return cachedContent
|
||||
}
|
||||
if err == nil && status >= 200 && status < 300 && content != "" {
|
||||
_ = writeFile(cacheFile, content)
|
||||
meta = opencodeCacheMetadata{
|
||||
ETag: etag,
|
||||
LastFetch: time.Now().UTC().Format(time.RFC3339),
|
||||
LastChecked: time.Now().UnixMilli(),
|
||||
}
|
||||
_ = writeJSON(metaFile, meta)
|
||||
return content
|
||||
}
|
||||
|
||||
return cachedContent
|
||||
}
|
||||
|
||||
func getOpenCodeCodexHeader() string {
|
||||
// 优先从 opencode 仓库缓存获取指令。
|
||||
opencodeInstructions := getOpenCodeCachedPrompt(opencodeCodexHeaderURL, "opencode-codex-header.txt", "opencode-codex-header-meta.json")
|
||||
|
||||
// 若 opencode 指令可用,直接返回。
|
||||
if opencodeInstructions != "" {
|
||||
return opencodeInstructions
|
||||
}
|
||||
|
||||
// 否则回退使用本地 Codex CLI 指令。
|
||||
// 兼容保留:历史上这里会从 opencode 仓库拉取 codex_header.txt。
|
||||
// 现在我们与 Codex CLI 一致,直接使用仓库内置的 instructions,避免读写缓存与外网依赖。
|
||||
return getCodexCLIInstructions()
|
||||
}
|
||||
|
||||
@@ -281,8 +218,8 @@ func GetCodexCLIInstructions() string {
|
||||
}
|
||||
|
||||
// applyInstructions 处理 instructions 字段
|
||||
// isCodexCLI=true: 仅补充缺失的 instructions(使用 opencode 指令)
|
||||
// isCodexCLI=false: 优先使用 opencode 指令覆盖
|
||||
// isCodexCLI=true: 仅补充缺失的 instructions(使用内置 Codex CLI 指令)
|
||||
// isCodexCLI=false: 优先使用内置 Codex CLI 指令覆盖
|
||||
func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
|
||||
if isCodexCLI {
|
||||
return applyCodexCLIInstructions(reqBody)
|
||||
@@ -291,13 +228,13 @@ func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
|
||||
}
|
||||
|
||||
// applyCodexCLIInstructions 为 Codex CLI 请求补充缺失的 instructions
|
||||
// 仅在 instructions 为空时添加 opencode 指令
|
||||
// 仅在 instructions 为空时添加内置 Codex CLI 指令(不依赖 opencode 缓存/回源)
|
||||
func applyCodexCLIInstructions(reqBody map[string]any) bool {
|
||||
if !isInstructionsEmpty(reqBody) {
|
||||
return false // 已有有效 instructions,不修改
|
||||
}
|
||||
|
||||
instructions := strings.TrimSpace(getOpenCodeCodexHeader())
|
||||
instructions := strings.TrimSpace(getCodexCLIInstructions())
|
||||
if instructions != "" {
|
||||
reqBody["instructions"] = instructions
|
||||
return true
|
||||
@@ -306,8 +243,8 @@ func applyCodexCLIInstructions(reqBody map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// applyOpenCodeInstructions 为非 Codex CLI 请求应用 opencode 指令
|
||||
// 优先使用 opencode 指令覆盖
|
||||
// applyOpenCodeInstructions 为非 Codex CLI 请求应用内置 Codex CLI 指令(兼容历史函数名)
|
||||
// 优先使用内置 Codex CLI 指令覆盖
|
||||
func applyOpenCodeInstructions(reqBody map[string]any) bool {
|
||||
instructions := strings.TrimSpace(getOpenCodeCodexHeader())
|
||||
existingInstructions, _ := reqBody["instructions"].(string)
|
||||
@@ -346,47 +283,6 @@ func isInstructionsEmpty(reqBody map[string]any) bool {
|
||||
return strings.TrimSpace(str) == ""
|
||||
}
|
||||
|
||||
// ReplaceWithCodexInstructions 将请求 instructions 替换为内置 Codex 指令(必要时)。
|
||||
func ReplaceWithCodexInstructions(reqBody map[string]any) bool {
|
||||
codexInstructions := strings.TrimSpace(getCodexCLIInstructions())
|
||||
if codexInstructions == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
existingInstructions, _ := reqBody["instructions"].(string)
|
||||
if strings.TrimSpace(existingInstructions) != codexInstructions {
|
||||
reqBody["instructions"] = codexInstructions
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsInstructionError 判断错误信息是否与指令格式/系统提示相关。
|
||||
func IsInstructionError(errorMessage string) bool {
|
||||
if errorMessage == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lowerMsg := strings.ToLower(errorMessage)
|
||||
instructionKeywords := []string{
|
||||
"instruction",
|
||||
"instructions",
|
||||
"system prompt",
|
||||
"system message",
|
||||
"invalid prompt",
|
||||
"prompt format",
|
||||
}
|
||||
|
||||
for _, keyword := range instructionKeywords {
|
||||
if strings.Contains(lowerMsg, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// filterCodexInput 按需过滤 item_reference 与 id。
|
||||
// preserveReferences 为 true 时保持引用与 id,以满足续链请求对上下文的依赖。
|
||||
func filterCodexInput(input []any, preserveReferences bool) []any {
|
||||
@@ -530,85 +426,3 @@ func normalizeCodexTools(reqBody map[string]any) bool {
|
||||
|
||||
return modified
|
||||
}
|
||||
|
||||
func codexCachePath(filename string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
cacheDir := filepath.Join(home, ".opencode", "cache")
|
||||
if filename == "" {
|
||||
return cacheDir
|
||||
}
|
||||
return filepath.Join(cacheDir, filename)
|
||||
}
|
||||
|
||||
func readFile(path string) (string, bool) {
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(data), true
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty cache path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func loadJSON(path string, target any) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty json path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func fetchWithETag(url, etag string) (string, string, int, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "sub2api-codex")
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", resp.StatusCode, err
|
||||
}
|
||||
return string(body), resp.Header.Get("etag"), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
|
||||
// 续链场景:保留 item_reference 与 id,但不再强制 store=true。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.2",
|
||||
@@ -48,7 +43,6 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||
// 续链场景:显式 store=false 不再强制为 true,保持 false。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -68,7 +62,6 @@ func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
|
||||
// 显式 store=true 也会强制为 false。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -88,7 +81,6 @@ func TestApplyCodexOAuthTransform_ExplicitStoreTrueForcedFalse(t *testing.T) {
|
||||
|
||||
func TestApplyCodexOAuthTransform_NonContinuationDefaultsStoreFalseAndStripsIDs(t *testing.T) {
|
||||
// 非续链场景:未设置 store 时默认 false,并移除 input 中的 id。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -130,8 +122,6 @@ func TestFilterCodexInput_RemovesItemReferenceWhenNotPreserved(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunctionTools(t *testing.T) {
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
"tools": []any{
|
||||
@@ -162,7 +152,6 @@ func TestApplyCodexOAuthTransform_NormalizeCodexTools_PreservesResponsesFunction
|
||||
|
||||
func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
|
||||
// 空 input 应保持为空且不触发异常。
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -189,30 +178,8 @@ func TestNormalizeCodexModel_Gpt53(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func setupCodexCache(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// 使用临时 HOME 避免触发网络拉取 header。
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("HOME", tempDir)
|
||||
|
||||
cacheDir := filepath.Join(tempDir, ".opencode", "cache")
|
||||
require.NoError(t, os.MkdirAll(cacheDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header.txt"), []byte("header"), 0o644))
|
||||
|
||||
meta := map[string]any{
|
||||
"etag": "",
|
||||
"lastFetch": time.Now().UTC().Format(time.RFC3339),
|
||||
"lastChecked": time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(meta)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(cacheDir, "opencode-codex-header-meta.json"), data, 0o644))
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *testing.T) {
|
||||
// Codex CLI 场景:已有 instructions 时不修改
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -230,7 +197,6 @@ func TestApplyCodexOAuthTransform_CodexCLI_PreservesExistingInstructions(t *test
|
||||
|
||||
func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T) {
|
||||
// Codex CLI 场景:无 instructions 时补充默认值
|
||||
setupCodexCache(t)
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
@@ -246,8 +212,7 @@ func TestApplyCodexOAuthTransform_CodexCLI_SuppliesDefaultWhenEmpty(t *testing.T
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_NonCodexCLI_OverridesInstructions(t *testing.T) {
|
||||
// 非 Codex CLI 场景:使用 opencode 指令覆盖
|
||||
setupCodexCache(t)
|
||||
// 非 Codex CLI 场景:使用内置 Codex CLI 指令覆盖
|
||||
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.1",
|
||||
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -332,7 +334,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
|
||||
|
||||
// 检查账号是否需要清理粘性会话
|
||||
// Check if sticky session should be cleared
|
||||
if shouldClearStickySession(account) {
|
||||
if shouldClearStickySession(account, requestedModel) {
|
||||
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), cacheKey)
|
||||
return nil
|
||||
}
|
||||
@@ -498,7 +500,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
|
||||
if err == nil && accountID > 0 && !isExcluded(accountID) {
|
||||
account, err := s.getSchedulableAccount(ctx, accountID)
|
||||
if err == nil {
|
||||
clearSticky := shouldClearStickySession(account)
|
||||
clearSticky := shouldClearStickySession(account, requestedModel)
|
||||
if clearSticky {
|
||||
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), "openai:"+sessionHash)
|
||||
}
|
||||
@@ -765,7 +767,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
bodyModified := false
|
||||
originalModel := reqModel
|
||||
|
||||
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent"))
|
||||
isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
|
||||
|
||||
// 对所有请求执行模型映射(包含 Codex CLI)。
|
||||
mappedModel := account.GetMappedModel(reqModel)
|
||||
@@ -969,6 +971,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
|
||||
if usage == nil {
|
||||
usage = &OpenAIUsage{}
|
||||
}
|
||||
|
||||
reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel)
|
||||
|
||||
return &OpenAIForwardResult{
|
||||
@@ -1053,6 +1059,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
req.Header.Set("user-agent", customUA)
|
||||
}
|
||||
|
||||
// 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。
|
||||
// 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("user-agent", "codex_cli_rs/0.98.0")
|
||||
}
|
||||
|
||||
// Ensure required headers exist
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
@@ -1087,6 +1099,30 @@ func (s *OpenAIGatewayService) handleErrorResponse(ctx context.Context, resp *ht
|
||||
)
|
||||
}
|
||||
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformOpenAI,
|
||||
resp.StatusCode,
|
||||
body,
|
||||
http.StatusBadGateway,
|
||||
"upstream_error",
|
||||
"Upstream request failed",
|
||||
); matched {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
if upstreamMsg == "" {
|
||||
upstreamMsg = errMsg
|
||||
}
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// Check custom error codes
|
||||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@@ -1209,7 +1245,8 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
scanner.Buffer(scanBuf[:0], maxLineSize)
|
||||
|
||||
type scanEvent struct {
|
||||
line string
|
||||
@@ -1228,7 +1265,8 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
}
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
go func(scanBuf *sseScannerBuf64K) {
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
@@ -1239,7 +1277,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
}(scanBuf)
|
||||
defer close(done)
|
||||
|
||||
streamInterval := time.Duration(0)
|
||||
@@ -1418,31 +1456,22 @@ func (s *OpenAIGatewayService) replaceModelInSSELine(line, fromModel, toModel st
|
||||
return line
|
||||
}
|
||||
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||
return line
|
||||
}
|
||||
|
||||
// Replace model in response
|
||||
if m, ok := event["model"].(string); ok && m == fromModel {
|
||||
event["model"] = toModel
|
||||
newData, err := json.Marshal(event)
|
||||
// 使用 gjson 精确检查 model 字段,避免全量 JSON 反序列化
|
||||
if m := gjson.Get(data, "model"); m.Exists() && m.Str == fromModel {
|
||||
newData, err := sjson.Set(data, "model", toModel)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return "data: " + string(newData)
|
||||
return "data: " + newData
|
||||
}
|
||||
|
||||
// Check nested response
|
||||
if response, ok := event["response"].(map[string]any); ok {
|
||||
if m, ok := response["model"].(string); ok && m == fromModel {
|
||||
response["model"] = toModel
|
||||
newData, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return "data: " + string(newData)
|
||||
// 检查嵌套的 response.model 字段
|
||||
if m := gjson.Get(data, "response.model"); m.Exists() && m.Str == fromModel {
|
||||
newData, err := sjson.Set(data, "response.model", toModel)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return "data: " + newData
|
||||
}
|
||||
|
||||
return line
|
||||
@@ -1662,23 +1691,15 @@ func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, erro
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return body
|
||||
// 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化
|
||||
if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel {
|
||||
newBody, err := sjson.SetBytes(body, "model", toModel)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return newBody
|
||||
}
|
||||
|
||||
model, ok := resp["model"].(string)
|
||||
if !ok || model != fromModel {
|
||||
return body
|
||||
}
|
||||
|
||||
resp["model"] = toModel
|
||||
newBody, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
|
||||
return newBody
|
||||
return body
|
||||
}
|
||||
|
||||
// OpenAIRecordUsageInput input for recording usage
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubOpenAIAccountRepo struct {
|
||||
@@ -204,6 +205,22 @@ func (c *stubGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *stubGatewayCache) IncrModelCallCount(ctx context.Context, accountID int64, model string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *stubGatewayCache) GetModelLoadBatch(ctx context.Context, accountIDs []int64, model string) (map[int64]*ModelLoadInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *stubGatewayCache) FindGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, found bool) {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func (c *stubGatewayCache) SaveGeminiSession(ctx context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) {
|
||||
now := time.Now()
|
||||
resetAt := now.Add(10 * time.Minute)
|
||||
@@ -1066,6 +1083,43 @@ func TestOpenAIStreamingHeadersOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingReuseScannerBufferAndStillWorks(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
StreamKeepaliveInterval: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: pr,
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() { _ = pw.Close() }()
|
||||
_, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"input_tokens_details\":{\"cached_tokens\":3}}}}\n\n"))
|
||||
}()
|
||||
|
||||
result, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
|
||||
_ = pr.Close()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.usage)
|
||||
require.Equal(t, 1, result.usage.InputTokens)
|
||||
require.Equal(t, 2, result.usage.OutputTokens)
|
||||
require.Equal(t, 3, result.usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
func TestOpenAIInvalidBaseURLWhenAllowlistDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
@@ -1149,3 +1203,226 @@ func TestOpenAIValidateUpstreamBaseURLEnabledEnforcesAllowlist(t *testing.T) {
|
||||
t.Fatalf("expected non-allowlisted host to fail")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== P1-08 修复:model 替换性能优化测试 ====================
|
||||
|
||||
func TestReplaceModelInSSELine(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
from string
|
||||
to string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "顶层 model 字段替换",
|
||||
line: `data: {"id":"chatcmpl-123","model":"gpt-4o","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-custom-model",
|
||||
expected: `data: {"id":"chatcmpl-123","model":"my-custom-model","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "嵌套 response.model 替换",
|
||||
line: `data: {"type":"response","response":{"id":"resp-1","model":"gpt-4o","output":[]}}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: {"type":"response","response":{"id":"resp-1","model":"my-model","output":[]}}`,
|
||||
},
|
||||
{
|
||||
name: "model 不匹配时不替换",
|
||||
line: `data: {"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: {"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "无 model 字段时不替换",
|
||||
line: `data: {"id":"chatcmpl-123","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: {"id":"chatcmpl-123","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "空 data 行",
|
||||
line: `data: `,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: `,
|
||||
},
|
||||
{
|
||||
name: "[DONE] 行",
|
||||
line: `data: [DONE]`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: [DONE]`,
|
||||
},
|
||||
{
|
||||
name: "非 data: 前缀行",
|
||||
line: `event: message`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `event: message`,
|
||||
},
|
||||
{
|
||||
name: "非法 JSON 不替换",
|
||||
line: `data: {invalid json}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: {invalid json}`,
|
||||
},
|
||||
{
|
||||
name: "无空格 data: 格式",
|
||||
line: `data:{"id":"x","model":"gpt-4o"}`,
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: `data: {"id":"x","model":"my-model"}`,
|
||||
},
|
||||
{
|
||||
name: "model 名含特殊字符",
|
||||
line: `data: {"model":"org/model-v2.1-beta"}`,
|
||||
from: "org/model-v2.1-beta",
|
||||
to: "custom/alias",
|
||||
expected: `data: {"model":"custom/alias"}`,
|
||||
},
|
||||
{
|
||||
name: "空行",
|
||||
line: "",
|
||||
from: "gpt-4o",
|
||||
to: "my-model",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "保持其他字段不变",
|
||||
line: `data: {"id":"abc","object":"chat.completion.chunk","model":"gpt-4o","created":1234567890,"choices":[{"index":0,"delta":{"content":"hi"}}]}`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `data: {"id":"abc","object":"chat.completion.chunk","model":"alias","created":1234567890,"choices":[{"index":0,"delta":{"content":"hi"}}]}`,
|
||||
},
|
||||
{
|
||||
name: "顶层优先于嵌套:同时存在两个 model",
|
||||
line: `data: {"model":"gpt-4o","response":{"model":"gpt-4o"}}`,
|
||||
from: "gpt-4o",
|
||||
to: "replaced",
|
||||
expected: `data: {"model":"replaced","response":{"model":"gpt-4o"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := svc.replaceModelInSSELine(tt.line, tt.from, tt.to)
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceModelInSSEBody(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
from string
|
||||
to string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "多行 SSE body 替换",
|
||||
body: "data: {\"model\":\"gpt-4o\",\"choices\":[]}\n\ndata: {\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n",
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: "data: {\"model\":\"alias\",\"choices\":[]}\n\ndata: {\"model\":\"alias\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n",
|
||||
},
|
||||
{
|
||||
name: "无需替换的 body",
|
||||
body: "data: {\"model\":\"gpt-3.5-turbo\"}\n\ndata: [DONE]\n",
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: "data: {\"model\":\"gpt-3.5-turbo\"}\n\ndata: [DONE]\n",
|
||||
},
|
||||
{
|
||||
name: "混合 event 和 data 行",
|
||||
body: "event: message\ndata: {\"model\":\"gpt-4o\"}\n\n",
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: "event: message\ndata: {\"model\":\"alias\"}\n\n",
|
||||
},
|
||||
{
|
||||
name: "空 body",
|
||||
body: "",
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := svc.replaceModelInSSEBody(tt.body, tt.from, tt.to)
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceModelInResponseBody(t *testing.T) {
|
||||
svc := &OpenAIGatewayService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
from string
|
||||
to string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "替换顶层 model",
|
||||
body: `{"id":"chatcmpl-123","model":"gpt-4o","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `{"id":"chatcmpl-123","model":"alias","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "model 不匹配不替换",
|
||||
body: `{"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `{"id":"chatcmpl-123","model":"gpt-3.5-turbo","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "无 model 字段不替换",
|
||||
body: `{"id":"chatcmpl-123","choices":[]}`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `{"id":"chatcmpl-123","choices":[]}`,
|
||||
},
|
||||
{
|
||||
name: "非法 JSON 返回原值",
|
||||
body: `not json`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `not json`,
|
||||
},
|
||||
{
|
||||
name: "空 body 返回原值",
|
||||
body: ``,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: ``,
|
||||
},
|
||||
{
|
||||
name: "保持嵌套结构不变",
|
||||
body: `{"model":"gpt-4o","usage":{"prompt_tokens":10,"completion_tokens":20},"choices":[{"message":{"role":"assistant","content":"hello"}}]}`,
|
||||
from: "gpt-4o",
|
||||
to: "alias",
|
||||
expected: `{"model":"alias","usage":{"prompt_tokens":10,"completion_tokens":20},"choices":[{"message":{"role":"assistant","content":"hello"}}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := svc.replaceModelInResponseBody([]byte(tt.body), tt.from, tt.to)
|
||||
require.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ func (s *OpsService) GetAccountAvailabilityStats(ctx context.Context, platformFi
|
||||
}
|
||||
|
||||
isAvailable := acc.Status == StatusActive && acc.Schedulable && !isRateLimited && !isOverloaded && !isTempUnsched
|
||||
|
||||
scopeRateLimits := acc.GetAntigravityScopeRateLimits()
|
||||
|
||||
if acc.Platform != "" {
|
||||
|
||||
@@ -255,3 +255,142 @@ func (s *OpsService) GetConcurrencyStats(
|
||||
|
||||
return platform, group, account, &collectedAt, nil
|
||||
}
|
||||
|
||||
// listAllActiveUsersForOps returns all active users with their concurrency settings.
|
||||
func (s *OpsService) listAllActiveUsersForOps(ctx context.Context) ([]User, error) {
|
||||
if s == nil || s.userRepo == nil {
|
||||
return []User{}, nil
|
||||
}
|
||||
|
||||
out := make([]User, 0, 128)
|
||||
page := 1
|
||||
for {
|
||||
users, pageInfo, err := s.userRepo.ListWithFilters(ctx, pagination.PaginationParams{
|
||||
Page: page,
|
||||
PageSize: opsAccountsPageSize,
|
||||
}, UserListFilters{
|
||||
Status: StatusActive,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
out = append(out, users...)
|
||||
if pageInfo != nil && int64(len(out)) >= pageInfo.Total {
|
||||
break
|
||||
}
|
||||
if len(users) < opsAccountsPageSize {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
if page > 10_000 {
|
||||
log.Printf("[Ops] listAllActiveUsersForOps: aborting after too many pages")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getUsersLoadMapBestEffort returns user load info for the given users.
|
||||
func (s *OpsService) getUsersLoadMapBestEffort(ctx context.Context, users []User) map[int64]*UserLoadInfo {
|
||||
if s == nil || s.concurrencyService == nil {
|
||||
return map[int64]*UserLoadInfo{}
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return map[int64]*UserLoadInfo{}
|
||||
}
|
||||
|
||||
// De-duplicate IDs (and keep the max concurrency to avoid under-reporting).
|
||||
unique := make(map[int64]int, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
if prev, ok := unique[u.ID]; !ok || u.Concurrency > prev {
|
||||
unique[u.ID] = u.Concurrency
|
||||
}
|
||||
}
|
||||
|
||||
batch := make([]UserWithConcurrency, 0, len(unique))
|
||||
for id, maxConc := range unique {
|
||||
batch = append(batch, UserWithConcurrency{
|
||||
ID: id,
|
||||
MaxConcurrency: maxConc,
|
||||
})
|
||||
}
|
||||
|
||||
out := make(map[int64]*UserLoadInfo, len(batch))
|
||||
for i := 0; i < len(batch); i += opsConcurrencyBatchChunkSize {
|
||||
end := i + opsConcurrencyBatchChunkSize
|
||||
if end > len(batch) {
|
||||
end = len(batch)
|
||||
}
|
||||
part, err := s.concurrencyService.GetUsersLoadBatch(ctx, batch[i:end])
|
||||
if err != nil {
|
||||
// Best-effort: return zeros rather than failing the ops UI.
|
||||
log.Printf("[Ops] GetUsersLoadBatch failed: %v", err)
|
||||
continue
|
||||
}
|
||||
for k, v := range part {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// GetUserConcurrencyStats returns real-time concurrency usage for all active users.
|
||||
func (s *OpsService) GetUserConcurrencyStats(ctx context.Context) (map[int64]*UserConcurrencyInfo, *time.Time, error) {
|
||||
if err := s.RequireMonitoringEnabled(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
users, err := s.listAllActiveUsersForOps(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
collectedAt := time.Now()
|
||||
loadMap := s.getUsersLoadMapBestEffort(ctx, users)
|
||||
|
||||
result := make(map[int64]*UserConcurrencyInfo)
|
||||
|
||||
for _, u := range users {
|
||||
if u.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
load := loadMap[u.ID]
|
||||
currentInUse := int64(0)
|
||||
waiting := int64(0)
|
||||
if load != nil {
|
||||
currentInUse = int64(load.CurrentConcurrency)
|
||||
waiting = int64(load.WaitingCount)
|
||||
}
|
||||
|
||||
// Skip users with no concurrency activity
|
||||
if currentInUse == 0 && waiting == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
info := &UserConcurrencyInfo{
|
||||
UserID: u.ID,
|
||||
UserEmail: u.Email,
|
||||
Username: u.Username,
|
||||
CurrentInUse: currentInUse,
|
||||
MaxCapacity: int64(u.Concurrency),
|
||||
WaitingInQueue: waiting,
|
||||
}
|
||||
if info.MaxCapacity > 0 {
|
||||
info.LoadPercentage = float64(info.CurrentInUse) / float64(info.MaxCapacity) * 100
|
||||
}
|
||||
result[u.ID] = info
|
||||
}
|
||||
|
||||
return result, &collectedAt, nil
|
||||
}
|
||||
|
||||
@@ -37,6 +37,17 @@ type AccountConcurrencyInfo struct {
|
||||
WaitingInQueue int64 `json:"waiting_in_queue"`
|
||||
}
|
||||
|
||||
// UserConcurrencyInfo represents real-time concurrency usage for a single user.
|
||||
type UserConcurrencyInfo struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email"`
|
||||
Username string `json:"username"`
|
||||
CurrentInUse int64 `json:"current_in_use"`
|
||||
MaxCapacity int64 `json:"max_capacity"`
|
||||
LoadPercentage float64 `json:"load_percentage"`
|
||||
WaitingInQueue int64 `json:"waiting_in_queue"`
|
||||
}
|
||||
|
||||
// PlatformAvailability aggregates account availability by platform.
|
||||
type PlatformAvailability struct {
|
||||
Platform string `json:"platform"`
|
||||
|
||||
@@ -576,7 +576,7 @@ func (s *OpsService) executeWithAccount(ctx context.Context, reqType opsRetryReq
|
||||
action = "streamGenerateContent"
|
||||
}
|
||||
if account.Platform == PlatformAntigravity {
|
||||
_, err = s.antigravityGatewayService.ForwardGemini(ctx, c, account, modelName, action, errorLog.Stream, body)
|
||||
_, err = s.antigravityGatewayService.ForwardGemini(ctx, c, account, modelName, action, errorLog.Stream, body, false)
|
||||
} else {
|
||||
_, err = s.geminiCompatService.ForwardNative(ctx, c, account, modelName, action, errorLog.Stream, body)
|
||||
}
|
||||
@@ -586,7 +586,7 @@ func (s *OpsService) executeWithAccount(ctx context.Context, reqType opsRetryReq
|
||||
if s.antigravityGatewayService == nil {
|
||||
return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "antigravity gateway service not available"}
|
||||
}
|
||||
_, err = s.antigravityGatewayService.Forward(ctx, c, account, body)
|
||||
_, err = s.antigravityGatewayService.Forward(ctx, c, account, body, false)
|
||||
case PlatformGemini:
|
||||
if s.geminiCompatService == nil {
|
||||
return &opsRetryExecution{status: opsRetryStatusFailed, errorMessage: "gemini gateway service not available"}
|
||||
|
||||
@@ -27,6 +27,7 @@ type OpsService struct {
|
||||
cfg *config.Config
|
||||
|
||||
accountRepo AccountRepository
|
||||
userRepo UserRepository
|
||||
|
||||
// getAccountAvailability is a unit-test hook for overriding account availability lookup.
|
||||
getAccountAvailability func(ctx context.Context, platformFilter string, groupIDFilter *int64) (*OpsAccountAvailability, error)
|
||||
@@ -43,6 +44,7 @@ func NewOpsService(
|
||||
settingRepo SettingRepository,
|
||||
cfg *config.Config,
|
||||
accountRepo AccountRepository,
|
||||
userRepo UserRepository,
|
||||
concurrencyService *ConcurrencyService,
|
||||
gatewayService *GatewayService,
|
||||
openAIGatewayService *OpenAIGatewayService,
|
||||
@@ -55,6 +57,7 @@ func NewOpsService(
|
||||
cfg: cfg,
|
||||
|
||||
accountRepo: accountRepo,
|
||||
userRepo: userRepo,
|
||||
|
||||
concurrencyService: concurrencyService,
|
||||
gatewayService: gatewayService,
|
||||
@@ -424,6 +427,26 @@ func isSensitiveKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Token 计数 / 预算字段不是凭据,应保留用于排错。
|
||||
// 白名单保持尽量窄,避免误把真实敏感信息"反脱敏"。
|
||||
switch k {
|
||||
case "max_tokens",
|
||||
"max_output_tokens",
|
||||
"max_input_tokens",
|
||||
"max_completion_tokens",
|
||||
"max_tokens_to_sample",
|
||||
"budget_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"token_count",
|
||||
"cache_creation_input_tokens",
|
||||
"cache_read_input_tokens":
|
||||
return false
|
||||
}
|
||||
|
||||
// Exact matches (common credential fields).
|
||||
switch k {
|
||||
case "authorization",
|
||||
@@ -566,7 +589,18 @@ func trimArrayField(root map[string]any, field string, maxBytes int) (map[string
|
||||
|
||||
func shrinkToEssentials(root map[string]any) map[string]any {
|
||||
out := make(map[string]any)
|
||||
for _, key := range []string{"model", "stream", "max_tokens", "temperature", "top_p", "top_k"} {
|
||||
for _, key := range []string{
|
||||
"model",
|
||||
"stream",
|
||||
"max_tokens",
|
||||
"max_output_tokens",
|
||||
"max_input_tokens",
|
||||
"max_completion_tokens",
|
||||
"thinking",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
} {
|
||||
if v, ok := root[key]; ok {
|
||||
out[key] = v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSensitiveKey_TokenBudgetKeysNotRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, key := range []string{
|
||||
"max_tokens",
|
||||
"max_output_tokens",
|
||||
"max_input_tokens",
|
||||
"max_completion_tokens",
|
||||
"max_tokens_to_sample",
|
||||
"budget_tokens",
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"token_count",
|
||||
} {
|
||||
if isSensitiveKey(key) {
|
||||
t.Fatalf("expected key %q to NOT be treated as sensitive", key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"session_token",
|
||||
"token",
|
||||
"client_secret",
|
||||
"private_key",
|
||||
"signature",
|
||||
} {
|
||||
if !isSensitiveKey(key) {
|
||||
t.Fatalf("expected key %q to be treated as sensitive", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeAndTrimRequestBody_PreservesTokenBudgetFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := []byte(`{"model":"claude-3","max_tokens":123,"thinking":{"type":"enabled","budget_tokens":456},"access_token":"abc","messages":[{"role":"user","content":"hi"}]}`)
|
||||
out, _, _ := sanitizeAndTrimRequestBody(raw, 10*1024)
|
||||
if out == "" {
|
||||
t.Fatalf("expected non-empty sanitized output")
|
||||
}
|
||||
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &decoded); err != nil {
|
||||
t.Fatalf("unmarshal sanitized output: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := decoded["max_tokens"].(float64); !ok || got != 123 {
|
||||
t.Fatalf("expected max_tokens=123, got %#v", decoded["max_tokens"])
|
||||
}
|
||||
|
||||
thinking, ok := decoded["thinking"].(map[string]any)
|
||||
if !ok || thinking == nil {
|
||||
t.Fatalf("expected thinking object to be preserved, got %#v", decoded["thinking"])
|
||||
}
|
||||
if got, ok := thinking["budget_tokens"].(float64); !ok || got != 456 {
|
||||
t.Fatalf("expected thinking.budget_tokens=456, got %#v", thinking["budget_tokens"])
|
||||
}
|
||||
|
||||
if got := decoded["access_token"]; got != "[REDACTED]" {
|
||||
t.Fatalf("expected access_token to be redacted, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShrinkToEssentials_IncludesThinking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := map[string]any{
|
||||
"model": "claude-3",
|
||||
"max_tokens": 100,
|
||||
"thinking": map[string]any{
|
||||
"type": "enabled",
|
||||
"budget_tokens": 200,
|
||||
},
|
||||
"messages": []any{
|
||||
map[string]any{"role": "user", "content": "first"},
|
||||
map[string]any{"role": "user", "content": "last"},
|
||||
},
|
||||
}
|
||||
|
||||
out := shrinkToEssentials(root)
|
||||
if _, ok := out["thinking"]; !ok {
|
||||
t.Fatalf("expected thinking to be included in essentials: %#v", out)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ var (
|
||||
type ProxyRepository interface {
|
||||
Create(ctx context.Context, proxy *Proxy) error
|
||||
GetByID(ctx context.Context, id int64) (*Proxy, error)
|
||||
ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error)
|
||||
Update(ctx context.Context, proxy *Proxy) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
|
||||
|
||||
@@ -387,14 +387,6 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
||||
|
||||
// 没有重置时间,使用默认5分钟
|
||||
resetAt := time.Now().Add(5 * time.Minute)
|
||||
if s.shouldScopeClaudeSonnetRateLimit(account, responseBody) {
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelRateLimitScopeClaudeSonnet, resetAt); err != nil {
|
||||
slog.Warn("model_rate_limit_set_failed", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "error", err)
|
||||
} else {
|
||||
slog.Info("account_model_rate_limited", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "reset_at", resetAt)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m")
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
@@ -407,14 +399,6 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
||||
if err != nil {
|
||||
slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err)
|
||||
resetAt := time.Now().Add(5 * time.Minute)
|
||||
if s.shouldScopeClaudeSonnetRateLimit(account, responseBody) {
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelRateLimitScopeClaudeSonnet, resetAt); err != nil {
|
||||
slog.Warn("model_rate_limit_set_failed", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "error", err)
|
||||
} else {
|
||||
slog.Info("account_model_rate_limited", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "reset_at", resetAt)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
@@ -423,15 +407,6 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
||||
|
||||
resetAt := time.Unix(ts, 0)
|
||||
|
||||
if s.shouldScopeClaudeSonnetRateLimit(account, responseBody) {
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelRateLimitScopeClaudeSonnet, resetAt); err != nil {
|
||||
slog.Warn("model_rate_limit_set_failed", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("account_model_rate_limited", "account_id", account.ID, "scope", modelRateLimitScopeClaudeSonnet, "reset_at", resetAt)
|
||||
return
|
||||
}
|
||||
|
||||
// 标记限流状态
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
@@ -448,17 +423,6 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
||||
slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt)
|
||||
}
|
||||
|
||||
func (s *RateLimitService) shouldScopeClaudeSonnetRateLimit(account *Account, responseBody []byte) bool {
|
||||
if account == nil || account.Platform != PlatformAnthropic {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "sonnet")
|
||||
}
|
||||
|
||||
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
|
||||
// 返回 nil 表示无法从响应头中确定重置时间
|
||||
func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *time.Time {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilterByMinPriority(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
result := filterByMinPriority(nil)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("single account", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, Priority: 5}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := filterByMinPriority(accounts)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, int64(1), result[0].account.ID)
|
||||
})
|
||||
|
||||
t.Run("multiple accounts same priority", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, Priority: 3}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, Priority: 3}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, Priority: 3}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := filterByMinPriority(accounts)
|
||||
require.Len(t, result, 3)
|
||||
})
|
||||
|
||||
t.Run("filters to min priority only", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, Priority: 5}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, Priority: 1}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, Priority: 3}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 4, Priority: 1}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := filterByMinPriority(accounts)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, int64(2), result[0].account.ID)
|
||||
require.Equal(t, int64(4), result[1].account.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterByMinLoadRate(t *testing.T) {
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
result := filterByMinLoadRate(nil)
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("single account", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
}
|
||||
result := filterByMinLoadRate(accounts)
|
||||
require.Len(t, result, 1)
|
||||
require.Equal(t, int64(1), result[0].account.ID)
|
||||
})
|
||||
|
||||
t.Run("multiple accounts same load rate", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{LoadRate: 20}},
|
||||
{account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{LoadRate: 20}},
|
||||
{account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{LoadRate: 20}},
|
||||
}
|
||||
result := filterByMinLoadRate(accounts)
|
||||
require.Len(t, result, 3)
|
||||
})
|
||||
|
||||
t.Run("filters to min load rate only", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{LoadRate: 80}},
|
||||
{account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{LoadRate: 10}},
|
||||
{account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
{account: &Account{ID: 4}, loadInfo: &AccountLoadInfo{LoadRate: 10}},
|
||||
}
|
||||
result := filterByMinLoadRate(accounts)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, int64(2), result[0].account.ID)
|
||||
require.Equal(t, int64(4), result[1].account.ID)
|
||||
})
|
||||
|
||||
t.Run("zero load rate", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
|
||||
{account: &Account{ID: 2}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
{account: &Account{ID: 3}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
|
||||
}
|
||||
result := filterByMinLoadRate(accounts)
|
||||
require.Len(t, result, 2)
|
||||
require.Equal(t, int64(1), result[0].account.ID)
|
||||
require.Equal(t, int64(3), result[1].account.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectByLRU(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-1 * time.Hour)
|
||||
muchEarlier := now.Add(-2 * time.Hour)
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
result := selectByLRU(nil, false)
|
||||
require.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("single account", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1), result.account.ID)
|
||||
})
|
||||
|
||||
t.Run("selects least recently used", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2), result.account.ID)
|
||||
})
|
||||
|
||||
t.Run("nil LastUsedAt preferred over non-nil", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: nil}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2), result.account.ID)
|
||||
})
|
||||
|
||||
t.Run("multiple nil LastUsedAt random selection", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
// 多次调用应该随机选择,验证结果都在候选范围内
|
||||
validIDs := map[int64]bool{1: true, 2: true, 3: true}
|
||||
for i := 0; i < 10; i++ {
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, validIDs[result.account.ID], "selected ID should be one of the candidates")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple same LastUsedAt random selection", func(t *testing.T) {
|
||||
sameTime := now
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: &sameTime}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: &sameTime}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
// 多次调用应该随机选择
|
||||
validIDs := map[int64]bool{1: true, 2: true}
|
||||
for i := 0; i < 10; i++ {
|
||||
result := selectByLRU(accounts, false)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, validIDs[result.account.ID], "selected ID should be one of the candidates")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preferOAuth selects from OAuth accounts when multiple nil", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: nil, Type: AccountTypeOAuth}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 3, LastUsedAt: nil, Type: AccountTypeOAuth}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
// preferOAuth 时,应该从 OAuth 类型中选择
|
||||
oauthIDs := map[int64]bool{2: true, 3: true}
|
||||
for i := 0; i < 10; i++ {
|
||||
result := selectByLRU(accounts, true)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, oauthIDs[result.account.ID], "should select from OAuth accounts")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preferOAuth falls back to all when no OAuth", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: nil, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
// 没有 OAuth 时,从所有候选中选择
|
||||
validIDs := map[int64]bool{1: true, 2: true}
|
||||
for i := 0; i < 10; i++ {
|
||||
result := selectByLRU(accounts, true)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, validIDs[result.account.ID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preferOAuth only affects same LastUsedAt accounts", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, LastUsedAt: &earlier, Type: "session"}, loadInfo: &AccountLoadInfo{}},
|
||||
{account: &Account{ID: 2, LastUsedAt: &now, Type: AccountTypeOAuth}, loadInfo: &AccountLoadInfo{}},
|
||||
}
|
||||
result := selectByLRU(accounts, true)
|
||||
require.NotNil(t, result)
|
||||
// 有不同 LastUsedAt 时,按时间选择最早的,不受 preferOAuth 影响
|
||||
require.Equal(t, int64(1), result.account.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayeredFilterIntegration(t *testing.T) {
|
||||
now := time.Now()
|
||||
earlier := now.Add(-1 * time.Hour)
|
||||
muchEarlier := now.Add(-2 * time.Hour)
|
||||
|
||||
t.Run("full layered selection", func(t *testing.T) {
|
||||
// 模拟真实场景:多个账号,不同优先级、负载率、最后使用时间
|
||||
accounts := []accountWithLoad{
|
||||
// 优先级 1,负载 50%
|
||||
{account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
// 优先级 1,负载 20%(最低)
|
||||
{account: &Account{ID: 2, Priority: 1, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{LoadRate: 20}},
|
||||
// 优先级 1,负载 20%(最低),更早使用
|
||||
{account: &Account{ID: 3, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 20}},
|
||||
// 优先级 2(较低优先)
|
||||
{account: &Account{ID: 4, Priority: 2, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
|
||||
}
|
||||
|
||||
// 1. 取优先级最小的集合 → ID: 1, 2, 3
|
||||
step1 := filterByMinPriority(accounts)
|
||||
require.Len(t, step1, 3)
|
||||
|
||||
// 2. 取负载率最低的集合 → ID: 2, 3
|
||||
step2 := filterByMinLoadRate(step1)
|
||||
require.Len(t, step2, 2)
|
||||
|
||||
// 3. LRU 选择 → ID: 3(muchEarlier 最早)
|
||||
selected := selectByLRU(step2, false)
|
||||
require.NotNil(t, selected)
|
||||
require.Equal(t, int64(3), selected.account.ID)
|
||||
})
|
||||
|
||||
t.Run("all same priority and load rate", func(t *testing.T) {
|
||||
accounts := []accountWithLoad{
|
||||
{account: &Account{ID: 1, Priority: 1, LastUsedAt: &now}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
{account: &Account{ID: 2, Priority: 1, LastUsedAt: &earlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
{account: &Account{ID: 3, Priority: 1, LastUsedAt: &muchEarlier}, loadInfo: &AccountLoadInfo{LoadRate: 50}},
|
||||
}
|
||||
|
||||
step1 := filterByMinPriority(accounts)
|
||||
require.Len(t, step1, 3)
|
||||
|
||||
step2 := filterByMinLoadRate(step1)
|
||||
require.Len(t, step2, 3)
|
||||
|
||||
// LRU 选择最早的
|
||||
selected := selectByLRU(step2, false)
|
||||
require.NotNil(t, selected)
|
||||
require.Equal(t, int64(3), selected.account.ID)
|
||||
})
|
||||
}
|
||||
@@ -151,6 +151,14 @@ func (s *SchedulerSnapshotService) GetAccount(ctx context.Context, accountID int
|
||||
return s.accountRepo.GetByID(fallbackCtx, accountID)
|
||||
}
|
||||
|
||||
// UpdateAccountInCache 立即更新 Redis 中单个账号的数据(用于模型限流后立即生效)
|
||||
func (s *SchedulerSnapshotService) UpdateAccountInCache(ctx context.Context, account *Account) error {
|
||||
if s.cache == nil || account == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cache.SetAccount(ctx, account)
|
||||
}
|
||||
|
||||
func (s *SchedulerSnapshotService) runInitialRebuild() {
|
||||
if s.cache == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "sync"
|
||||
|
||||
const sseScannerBuf64KSize = 64 * 1024
|
||||
|
||||
type sseScannerBuf64K [sseScannerBuf64KSize]byte
|
||||
|
||||
var sseScannerBuf64KPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(sseScannerBuf64K)
|
||||
},
|
||||
}
|
||||
|
||||
func getSSEScannerBuf64K() *sseScannerBuf64K {
|
||||
return sseScannerBuf64KPool.Get().(*sseScannerBuf64K)
|
||||
}
|
||||
|
||||
func putSSEScannerBuf64K(buf *sseScannerBuf64K) {
|
||||
if buf == nil {
|
||||
return
|
||||
}
|
||||
sseScannerBuf64KPool.Put(buf)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSEScannerBuf64KPool_GetPutDoesNotPanic(t *testing.T) {
|
||||
buf := getSSEScannerBuf64K()
|
||||
require.NotNil(t, buf)
|
||||
require.Equal(t, sseScannerBuf64KSize, len(buf[:]))
|
||||
|
||||
buf[0] = 1
|
||||
putSSEScannerBuf64K(buf)
|
||||
|
||||
// 允许传入 nil,确保不会 panic
|
||||
putSSEScannerBuf64K(nil)
|
||||
}
|
||||
@@ -23,32 +23,90 @@ import (
|
||||
// - 临时不可调度且未过期:清理
|
||||
// - 临时不可调度已过期:不清理
|
||||
// - 正常可调度状态:不清理
|
||||
// - 模型限流超过阈值:清理
|
||||
// - 模型限流未超过阈值:不清理
|
||||
//
|
||||
// TestShouldClearStickySession tests the sticky session clearing logic.
|
||||
// Verifies correct behavior for various account states including:
|
||||
// nil account, error/disabled status, unschedulable, temporary unschedulable.
|
||||
// nil account, error/disabled status, unschedulable, temporary unschedulable,
|
||||
// and model rate limiting scenarios.
|
||||
func TestShouldClearStickySession(t *testing.T) {
|
||||
now := time.Now()
|
||||
future := now.Add(1 * time.Hour)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
|
||||
// 短限流时间(低于阈值,不应清除粘性会话)
|
||||
shortRateLimitReset := now.Add(5 * time.Second).Format(time.RFC3339)
|
||||
// 长限流时间(超过阈值,应清除粘性会话)
|
||||
longRateLimitReset := now.Add(30 * time.Second).Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
want bool
|
||||
name string
|
||||
account *Account
|
||||
requestedModel string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil account", account: nil, want: false},
|
||||
{name: "status error", account: &Account{Status: StatusError, Schedulable: true}, want: true},
|
||||
{name: "status disabled", account: &Account{Status: StatusDisabled, Schedulable: true}, want: true},
|
||||
{name: "schedulable false", account: &Account{Status: StatusActive, Schedulable: false}, want: true},
|
||||
{name: "temp unschedulable", account: &Account{Status: StatusActive, Schedulable: true, TempUnschedulableUntil: &future}, want: true},
|
||||
{name: "temp unschedulable expired", account: &Account{Status: StatusActive, Schedulable: true, TempUnschedulableUntil: &past}, want: false},
|
||||
{name: "active schedulable", account: &Account{Status: StatusActive, Schedulable: true}, want: false},
|
||||
{name: "nil account", account: nil, requestedModel: "", want: false},
|
||||
{name: "status error", account: &Account{Status: StatusError, Schedulable: true}, requestedModel: "", want: true},
|
||||
{name: "status disabled", account: &Account{Status: StatusDisabled, Schedulable: true}, requestedModel: "", want: true},
|
||||
{name: "schedulable false", account: &Account{Status: StatusActive, Schedulable: false}, requestedModel: "", want: true},
|
||||
{name: "temp unschedulable", account: &Account{Status: StatusActive, Schedulable: true, TempUnschedulableUntil: &future}, requestedModel: "", want: true},
|
||||
{name: "temp unschedulable expired", account: &Account{Status: StatusActive, Schedulable: true, TempUnschedulableUntil: &past}, requestedModel: "", want: false},
|
||||
{name: "active schedulable", account: &Account{Status: StatusActive, Schedulable: true}, requestedModel: "", want: false},
|
||||
// 模型限流测试
|
||||
{
|
||||
name: "model rate limited short duration",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
"claude-sonnet-4": map[string]any{
|
||||
"rate_limit_reset_at": shortRateLimitReset,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4",
|
||||
want: false, // 低于阈值,不清除
|
||||
},
|
||||
{
|
||||
name: "model rate limited long duration",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
"claude-sonnet-4": map[string]any{
|
||||
"rate_limit_reset_at": longRateLimitReset,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-sonnet-4",
|
||||
want: true, // 超过阈值,清除
|
||||
},
|
||||
{
|
||||
name: "model rate limited different model",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Extra: map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
"claude-sonnet-4": map[string]any{
|
||||
"rate_limit_reset_at": longRateLimitReset,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
requestedModel: "claude-opus-4", // 请求不同模型
|
||||
want: false, // 不同模型不受影响
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldClearStickySession(tt.account))
|
||||
require.Equal(t, tt.want, shouldClearStickySession(tt.account, tt.requestedModel))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/dgraph-io/ristretto"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// MaxExpiresAt is the maximum allowed expiration date (year 2099)
|
||||
@@ -35,15 +40,76 @@ type SubscriptionService struct {
|
||||
groupRepo GroupRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
billingCacheService *BillingCacheService
|
||||
|
||||
// L1 缓存:加速中间件热路径的订阅查询
|
||||
subCacheL1 *ristretto.Cache
|
||||
subCacheGroup singleflight.Group
|
||||
subCacheTTL time.Duration
|
||||
subCacheJitter int // 抖动百分比
|
||||
}
|
||||
|
||||
// NewSubscriptionService 创建订阅服务
|
||||
func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, billingCacheService *BillingCacheService) *SubscriptionService {
|
||||
return &SubscriptionService{
|
||||
func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, billingCacheService *BillingCacheService, cfg *config.Config) *SubscriptionService {
|
||||
svc := &SubscriptionService{
|
||||
groupRepo: groupRepo,
|
||||
userSubRepo: userSubRepo,
|
||||
billingCacheService: billingCacheService,
|
||||
}
|
||||
svc.initSubCache(cfg)
|
||||
return svc
|
||||
}
|
||||
|
||||
// initSubCache 初始化订阅 L1 缓存
|
||||
func (s *SubscriptionService) initSubCache(cfg *config.Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
sc := cfg.SubscriptionCache
|
||||
if sc.L1Size <= 0 || sc.L1TTLSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
cache, err := ristretto.NewCache(&ristretto.Config{
|
||||
NumCounters: int64(sc.L1Size) * 10,
|
||||
MaxCost: int64(sc.L1Size),
|
||||
BufferItems: 64,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to init subscription L1 cache: %v", err)
|
||||
return
|
||||
}
|
||||
s.subCacheL1 = cache
|
||||
s.subCacheTTL = time.Duration(sc.L1TTLSeconds) * time.Second
|
||||
s.subCacheJitter = sc.JitterPercent
|
||||
}
|
||||
|
||||
// subCacheKey 生成订阅缓存 key(热路径,避免 fmt.Sprintf 开销)
|
||||
func subCacheKey(userID, groupID int64) string {
|
||||
return "sub:" + strconv.FormatInt(userID, 10) + ":" + strconv.FormatInt(groupID, 10)
|
||||
}
|
||||
|
||||
// jitteredTTL 为 TTL 添加抖动,避免集中过期
|
||||
func (s *SubscriptionService) jitteredTTL(ttl time.Duration) time.Duration {
|
||||
if ttl <= 0 || s.subCacheJitter <= 0 {
|
||||
return ttl
|
||||
}
|
||||
pct := s.subCacheJitter
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
delta := float64(pct) / 100
|
||||
factor := 1 - delta + rand.Float64()*(2*delta)
|
||||
if factor <= 0 {
|
||||
return ttl
|
||||
}
|
||||
return time.Duration(float64(ttl) * factor)
|
||||
}
|
||||
|
||||
// InvalidateSubCache 失效指定用户+分组的订阅 L1 缓存
|
||||
func (s *SubscriptionService) InvalidateSubCache(userID, groupID int64) {
|
||||
if s.subCacheL1 == nil {
|
||||
return
|
||||
}
|
||||
s.subCacheL1.Del(subCacheKey(userID, groupID))
|
||||
}
|
||||
|
||||
// AssignSubscriptionInput 分配订阅输入
|
||||
@@ -81,6 +147,7 @@ func (s *SubscriptionService) AssignSubscription(ctx context.Context, input *Ass
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(input.UserID, input.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := input.UserID, input.GroupID
|
||||
go func() {
|
||||
@@ -167,6 +234,7 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(input.UserID, input.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := input.UserID, input.GroupID
|
||||
go func() {
|
||||
@@ -188,6 +256,7 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(input.UserID, input.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := input.UserID, input.GroupID
|
||||
go func() {
|
||||
@@ -297,6 +366,7 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := sub.UserID, sub.GroupID
|
||||
go func() {
|
||||
@@ -363,6 +433,7 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
|
||||
}
|
||||
|
||||
// 失效订阅缓存
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
userID, groupID := sub.UserID, sub.GroupID
|
||||
go func() {
|
||||
@@ -381,12 +452,39 @@ func (s *SubscriptionService) GetByID(ctx context.Context, id int64) (*UserSubsc
|
||||
}
|
||||
|
||||
// GetActiveSubscription 获取用户对特定分组的有效订阅
|
||||
// 使用 L1 缓存 + singleflight 加速中间件热路径。
|
||||
// 返回缓存对象的浅拷贝,调用方可安全修改字段而不会污染缓存或触发 data race。
|
||||
func (s *SubscriptionService) GetActiveSubscription(ctx context.Context, userID, groupID int64) (*UserSubscription, error) {
|
||||
sub, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, userID, groupID)
|
||||
if err != nil {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
key := subCacheKey(userID, groupID)
|
||||
|
||||
// L1 缓存命中:返回浅拷贝
|
||||
if s.subCacheL1 != nil {
|
||||
if v, ok := s.subCacheL1.Get(key); ok {
|
||||
if sub, ok := v.(*UserSubscription); ok {
|
||||
cp := *sub
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return sub, nil
|
||||
|
||||
// singleflight 防止并发击穿
|
||||
value, err, _ := s.subCacheGroup.Do(key, func() (any, error) {
|
||||
sub, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, userID, groupID)
|
||||
if err != nil {
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
// 写入 L1 缓存
|
||||
if s.subCacheL1 != nil {
|
||||
_ = s.subCacheL1.SetWithTTL(key, sub, 1, s.jitteredTTL(s.subCacheTTL))
|
||||
}
|
||||
return sub, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// singleflight 返回的也是缓存指针,需要浅拷贝
|
||||
cp := *value.(*UserSubscription)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// ListUserSubscriptions 获取用户的所有订阅
|
||||
@@ -521,9 +619,12 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
|
||||
needsInvalidateCache = true
|
||||
}
|
||||
|
||||
// 如果有窗口被重置,失效 Redis 缓存以保持一致性
|
||||
if needsInvalidateCache && s.billingCacheService != nil {
|
||||
_ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
|
||||
// 如果有窗口被重置,失效缓存以保持一致性
|
||||
if needsInvalidateCache {
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
if s.billingCacheService != nil {
|
||||
_ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -544,6 +645,78 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndCheckLimits 合并验证+限额检查(中间件热路径专用)
|
||||
// 仅做内存检查,不触发 DB 写入。窗口重置的 DB 写入由 DoWindowMaintenance 异步完成。
|
||||
// 返回 needsMaintenance 表示是否需要异步执行窗口维护。
|
||||
func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) {
|
||||
// 1. 验证订阅状态
|
||||
if sub.Status == SubscriptionStatusExpired {
|
||||
return false, ErrSubscriptionExpired
|
||||
}
|
||||
if sub.Status == SubscriptionStatusSuspended {
|
||||
return false, ErrSubscriptionSuspended
|
||||
}
|
||||
if sub.IsExpired() {
|
||||
return false, ErrSubscriptionExpired
|
||||
}
|
||||
|
||||
// 2. 内存中修正过期窗口的用量,确保 CheckUsageLimits 不会误拒绝用户
|
||||
// 实际的 DB 窗口重置由 DoWindowMaintenance 异步完成
|
||||
if sub.NeedsDailyReset() {
|
||||
sub.DailyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsWeeklyReset() {
|
||||
sub.WeeklyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsMonthlyReset() {
|
||||
sub.MonthlyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if !sub.IsWindowActivated() {
|
||||
needsMaintenance = true
|
||||
}
|
||||
|
||||
// 3. 检查用量限额
|
||||
if !sub.CheckDailyLimit(group, 0) {
|
||||
return needsMaintenance, ErrDailyLimitExceeded
|
||||
}
|
||||
if !sub.CheckWeeklyLimit(group, 0) {
|
||||
return needsMaintenance, ErrWeeklyLimitExceeded
|
||||
}
|
||||
if !sub.CheckMonthlyLimit(group, 0) {
|
||||
return needsMaintenance, ErrMonthlyLimitExceeded
|
||||
}
|
||||
|
||||
return needsMaintenance, nil
|
||||
}
|
||||
|
||||
// DoWindowMaintenance 异步执行窗口维护(激活+重置)
|
||||
// 使用独立 context,不受请求取消影响。
|
||||
// 注意:此方法仅在 ValidateAndCheckLimits 返回 needsMaintenance=true 时调用,
|
||||
// 而 IsExpired()=true 的订阅在 ValidateAndCheckLimits 中已被拦截返回错误,
|
||||
// 因此进入此方法的订阅一定未过期,无需处理过期状态同步。
|
||||
func (s *SubscriptionService) DoWindowMaintenance(sub *UserSubscription) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 激活窗口(首次使用时)
|
||||
if !sub.IsWindowActivated() {
|
||||
if err := s.CheckAndActivateWindow(ctx, sub); err != nil {
|
||||
log.Printf("Failed to activate subscription windows: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置过期窗口
|
||||
if err := s.CheckAndResetWindows(ctx, sub); err != nil {
|
||||
log.Printf("Failed to reset subscription windows: %v", err)
|
||||
}
|
||||
|
||||
// 失效 L1 缓存,确保后续请求拿到更新后的数据
|
||||
s.InvalidateSubCache(sub.UserID, sub.GroupID)
|
||||
}
|
||||
|
||||
// RecordUsage 记录使用量到订阅
|
||||
func (s *SubscriptionService) RecordUsage(ctx context.Context, subscriptionID int64, costUSD float64) error {
|
||||
return s.userSubRepo.IncrementUsage(ctx, subscriptionID, costUSD)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ============ 临时限流单元测试 ============
|
||||
|
||||
// TestMatchTempUnschedKeyword 测试关键词匹配函数
|
||||
func TestMatchTempUnschedKeyword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
keywords []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "match_first",
|
||||
body: "server is overloaded",
|
||||
keywords: []string{"overloaded", "capacity"},
|
||||
want: "overloaded",
|
||||
},
|
||||
{
|
||||
name: "match_second",
|
||||
body: "no capacity available",
|
||||
keywords: []string{"overloaded", "capacity"},
|
||||
want: "capacity",
|
||||
},
|
||||
{
|
||||
name: "no_match",
|
||||
body: "internal error",
|
||||
keywords: []string{"overloaded", "capacity"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_body",
|
||||
body: "",
|
||||
keywords: []string{"overloaded"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty_keywords",
|
||||
body: "server is overloaded",
|
||||
keywords: []string{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace_keyword",
|
||||
body: "server is overloaded",
|
||||
keywords: []string{" ", "overloaded"},
|
||||
want: "overloaded",
|
||||
},
|
||||
{
|
||||
// matchTempUnschedKeyword 期望 body 已经是小写的
|
||||
// 所以要测试大小写不敏感匹配,需要传入小写的 body
|
||||
name: "case_insensitive_body_lowered",
|
||||
body: "server is overloaded", // body 已经是小写
|
||||
keywords: []string{"OVERLOADED"}, // keyword 会被转为小写比较
|
||||
want: "OVERLOADED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchTempUnschedKeyword(tt.body, tt.keywords)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountIsSchedulable_TempUnschedulable 测试临时限流账号不可调度
|
||||
func TestAccountIsSchedulable_TempUnschedulable(t *testing.T) {
|
||||
future := time.Now().Add(10 * time.Minute)
|
||||
past := time.Now().Add(-10 * time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "temp_unschedulable_active",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: &future,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "temp_unschedulable_expired",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: &past,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no_temp_unschedulable",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: nil,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "temp_unschedulable_with_rate_limit",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: &future,
|
||||
RateLimitResetAt: &past, // 过期的限流不影响
|
||||
},
|
||||
want: false, // 临时限流生效
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.account.IsSchedulable()
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccount_IsTempUnschedulableEnabled 测试临时限流开关
|
||||
func TestAccount_IsTempUnschedulableEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "enabled",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_enabled": true,
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_enabled": false,
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "not_set",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil_credentials",
|
||||
account: &Account{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.account.IsTempUnschedulableEnabled()
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccount_GetTempUnschedulableRules 测试获取临时限流规则
|
||||
func TestAccount_GetTempUnschedulableRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "has_rules",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_rules": []any{
|
||||
map[string]any{
|
||||
"error_code": float64(503),
|
||||
"keywords": []any{"overloaded"},
|
||||
"duration_minutes": float64(5),
|
||||
},
|
||||
map[string]any{
|
||||
"error_code": float64(500),
|
||||
"keywords": []any{"internal"},
|
||||
"duration_minutes": float64(10),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCount: 2,
|
||||
},
|
||||
{
|
||||
name: "empty_rules",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_rules": []any{},
|
||||
},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "no_rules",
|
||||
account: &Account{
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "nil_credentials",
|
||||
account: &Account{},
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rules := tt.account.GetTempUnschedulableRules()
|
||||
require.Len(t, rules, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempUnschedulableRule_Parse 测试规则解析
|
||||
func TestTempUnschedulableRule_Parse(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_rules": []any{
|
||||
map[string]any{
|
||||
"error_code": float64(503),
|
||||
"keywords": []any{"overloaded", "capacity"},
|
||||
"duration_minutes": float64(5),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rules := account.GetTempUnschedulableRules()
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
rule := rules[0]
|
||||
require.Equal(t, 503, rule.ErrorCode)
|
||||
require.Equal(t, []string{"overloaded", "capacity"}, rule.Keywords)
|
||||
require.Equal(t, 5, rule.DurationMinutes)
|
||||
}
|
||||
|
||||
// TestTruncateTempUnschedMessage 测试消息截断
|
||||
func TestTruncateTempUnschedMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
maxBytes int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "short_message",
|
||||
body: []byte("short"),
|
||||
maxBytes: 100,
|
||||
want: "short",
|
||||
},
|
||||
{
|
||||
// 截断后会 TrimSpace,所以末尾的空格会被移除
|
||||
name: "truncate_long_message",
|
||||
body: []byte("this is a very long message that needs to be truncated"),
|
||||
maxBytes: 20,
|
||||
want: "this is a very long", // 截断后 TrimSpace
|
||||
},
|
||||
{
|
||||
name: "empty_body",
|
||||
body: []byte{},
|
||||
maxBytes: 100,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "zero_max_bytes",
|
||||
body: []byte("test"),
|
||||
maxBytes: 0,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace_trimmed",
|
||||
body: []byte(" test "),
|
||||
maxBytes: 100,
|
||||
want: "test",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := truncateTempUnschedMessage(tt.body, tt.maxBytes)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempUnschedState 测试临时限流状态结构
|
||||
func TestTempUnschedState(t *testing.T) {
|
||||
now := time.Now()
|
||||
until := now.Add(5 * time.Minute)
|
||||
|
||||
state := &TempUnschedState{
|
||||
UntilUnix: until.Unix(),
|
||||
TriggeredAtUnix: now.Unix(),
|
||||
StatusCode: 503,
|
||||
MatchedKeyword: "overloaded",
|
||||
RuleIndex: 0,
|
||||
ErrorMessage: "Server is overloaded",
|
||||
}
|
||||
|
||||
require.Equal(t, 503, state.StatusCode)
|
||||
require.Equal(t, "overloaded", state.MatchedKeyword)
|
||||
require.Equal(t, 0, state.RuleIndex)
|
||||
|
||||
// 验证时间戳
|
||||
require.Equal(t, until.Unix(), state.UntilUnix)
|
||||
require.Equal(t, now.Unix(), state.TriggeredAtUnix)
|
||||
}
|
||||
|
||||
// TestAccount_TempUnschedulableUntil 测试临时限流时间字段
|
||||
func TestAccount_TempUnschedulableUntil(t *testing.T) {
|
||||
future := time.Now().Add(10 * time.Minute)
|
||||
past := time.Now().Add(-10 * time.Minute)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
schedulable bool
|
||||
}{
|
||||
{
|
||||
name: "active_temp_unsched_not_schedulable",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: &future,
|
||||
},
|
||||
schedulable: false,
|
||||
},
|
||||
{
|
||||
name: "expired_temp_unsched_is_schedulable",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: &past,
|
||||
},
|
||||
schedulable: true,
|
||||
},
|
||||
{
|
||||
name: "nil_temp_unsched_is_schedulable",
|
||||
account: &Account{
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
TempUnschedulableUntil: nil,
|
||||
},
|
||||
schedulable: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.account.IsSchedulable()
|
||||
require.Equal(t, tt.schedulable, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -316,8 +316,8 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star
|
||||
}
|
||||
|
||||
// GetBatchAPIKeyUsageStats returns today/total actual_cost for given api keys.
|
||||
func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs)
|
||||
func (s *UsageService) GetBatchAPIKeyUsageStats(ctx context.Context, apiKeyIDs []int64, startTime, endTime time.Time) (map[int64]*usagestats.BatchAPIKeyUsageStats, error) {
|
||||
stats, err := s.usageRepo.GetBatchAPIKeyUsageStats(ctx, apiKeyIDs, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
@@ -62,13 +64,15 @@ type ChangePasswordRequest struct {
|
||||
type UserService struct {
|
||||
userRepo UserRepository
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||
billingCache BillingCache
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务实例
|
||||
func NewUserService(userRepo UserRepository, authCacheInvalidator APIKeyAuthCacheInvalidator) *UserService {
|
||||
func NewUserService(userRepo UserRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
|
||||
return &UserService{
|
||||
userRepo: userRepo,
|
||||
authCacheInvalidator: authCacheInvalidator,
|
||||
billingCache: billingCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +187,15 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
}
|
||||
if s.billingCache != nil {
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.billingCache.InvalidateUserBalance(cacheCtx, userID); err != nil {
|
||||
log.Printf("invalidate user balance cache failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- mock: UserRepository ---
|
||||
|
||||
type mockUserRepo struct {
|
||||
updateBalanceErr error
|
||||
updateBalanceFn func(ctx context.Context, id int64, amount float64) error
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) GetByID(context.Context, int64) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) GetByEmail(context.Context, string) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) GetFirstAdmin(context.Context) (*User, error) { return &User{}, nil }
|
||||
func (m *mockUserRepo) Update(context.Context, *User) error { return nil }
|
||||
func (m *mockUserRepo) Delete(context.Context, int64) error { return nil }
|
||||
func (m *mockUserRepo) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockUserRepo) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
|
||||
if m.updateBalanceFn != nil {
|
||||
return m.updateBalanceFn(ctx, id, amount)
|
||||
}
|
||||
return m.updateBalanceErr
|
||||
}
|
||||
func (m *mockUserRepo) DeductBalance(context.Context, int64, float64) error { return nil }
|
||||
func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil }
|
||||
func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
|
||||
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
|
||||
func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
|
||||
func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }
|
||||
|
||||
// --- mock: APIKeyAuthCacheInvalidator ---
|
||||
|
||||
type mockAuthCacheInvalidator struct {
|
||||
invalidatedUserIDs []int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByKey(context.Context, string) {}
|
||||
func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByGroupID(context.Context, int64) {}
|
||||
func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
|
||||
}
|
||||
|
||||
// --- mock: BillingCache ---
|
||||
|
||||
type mockBillingCache struct {
|
||||
invalidateErr error
|
||||
invalidateCallCount atomic.Int64
|
||||
invalidatedUserIDs []int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (m *mockBillingCache) GetUserBalance(context.Context, int64) (float64, error) { return 0, nil }
|
||||
func (m *mockBillingCache) SetUserBalance(context.Context, int64, float64) error { return nil }
|
||||
func (m *mockBillingCache) DeductUserBalance(context.Context, int64, float64) error { return nil }
|
||||
func (m *mockBillingCache) InvalidateUserBalance(_ context.Context, userID int64) error {
|
||||
m.invalidateCallCount.Add(1)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
|
||||
return m.invalidateErr
|
||||
}
|
||||
func (m *mockBillingCache) GetSubscriptionCache(context.Context, int64, int64) (*SubscriptionCacheData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockBillingCache) SetSubscriptionCache(context.Context, int64, int64, *SubscriptionCacheData) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockBillingCache) UpdateSubscriptionUsage(context.Context, int64, int64, float64) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockBillingCache) InvalidateSubscriptionCache(context.Context, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 测试 ---
|
||||
|
||||
func TestUpdateBalance_Success(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 42, 100.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 等待异步 goroutine 完成
|
||||
require.Eventually(t, func() bool {
|
||||
return cache.invalidateCallCount.Load() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond, "应异步调用 InvalidateUserBalance")
|
||||
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
require.Equal(t, []int64{42}, cache.invalidatedUserIDs, "应对 userID=42 失效缓存")
|
||||
}
|
||||
|
||||
func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
svc := NewUserService(repo, nil, nil) // billingCache = nil
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 1, 50.0)
|
||||
require.NoError(t, err, "billingCache 为 nil 时不应 panic")
|
||||
}
|
||||
|
||||
func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
cache := &mockBillingCache{invalidateErr: errors.New("redis connection refused")}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 99, 200.0)
|
||||
require.NoError(t, err, "缓存失效失败不应影响主流程返回值")
|
||||
|
||||
// 等待异步 goroutine 完成(即使失败也应调用)
|
||||
require.Eventually(t, func() bool {
|
||||
return cache.invalidateCallCount.Load() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond, "即使失败也应调用 InvalidateUserBalance")
|
||||
}
|
||||
|
||||
func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
|
||||
repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, nil, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 1, 100.0)
|
||||
require.Error(t, err, "repo 失败时应返回错误")
|
||||
require.Contains(t, err.Error(), "update balance")
|
||||
|
||||
// repo 失败时不应触发缓存失效
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.Equal(t, int64(0), cache.invalidateCallCount.Load(),
|
||||
"repo 失败时不应调用 InvalidateUserBalance")
|
||||
}
|
||||
|
||||
func TestUpdateBalance_WithAuthCacheInvalidator(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
auth := &mockAuthCacheInvalidator{}
|
||||
cache := &mockBillingCache{}
|
||||
svc := NewUserService(repo, auth, cache)
|
||||
|
||||
err := svc.UpdateBalance(context.Background(), 77, 300.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证 auth cache 同步失效
|
||||
auth.mu.Lock()
|
||||
require.Equal(t, []int64{77}, auth.invalidatedUserIDs)
|
||||
auth.mu.Unlock()
|
||||
|
||||
// 验证 billing cache 异步失效
|
||||
require.Eventually(t, func() bool {
|
||||
return cache.invalidateCallCount.Load() == 1
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestNewUserService_FieldsAssignment(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
auth := &mockAuthCacheInvalidator{}
|
||||
cache := &mockBillingCache{}
|
||||
|
||||
svc := NewUserService(repo, auth, cache)
|
||||
require.NotNil(t, svc)
|
||||
require.Equal(t, repo, svc.userRepo)
|
||||
require.Equal(t, auth, svc.authCacheInvalidator)
|
||||
require.Equal(t, cache, svc.billingCache)
|
||||
}
|
||||
Reference in New Issue
Block a user