feat(antigravity): 添加混合调度可选功能
- 后端:账户模型添加 IsMixedSchedulingEnabled() 方法,读取 extra.mixed_scheduling - 后端:gateway_service 和 gemini_messages_compat_service 支持混合调度逻辑 - 后端:分组创建支持指定 platform 参数 - 前端:账户创建/编辑弹窗添加混合调度开关(仅 antigravity 账户显示) - 前端:混合调度开关添加问号图标和 tooltip 说明 - 前端:GroupSelector 支持根据 mixedScheduling 属性过滤分组 - 前端:分组创建支持选择 platform - 测试:e2e 测试添加 ENDPOINT_PREFIX 环境变量支持混合/隔离模式测试 - 测试:删除过时的 Claude signature 测试用例
This commit is contained in:
@@ -121,10 +121,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
timingWheelService := service.ProvideTimingWheelService()
|
timingWheelService := service.ProvideTimingWheelService()
|
||||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||||
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
||||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
||||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func NewGroupHandler(adminService service.AdminService) *GroupHandler {
|
|||||||
type CreateGroupRequest struct {
|
type CreateGroupRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini"`
|
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||||
RateMultiplier float64 `json:"rate_multiplier"`
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
IsExclusive bool `json:"is_exclusive"`
|
IsExclusive bool `json:"is_exclusive"`
|
||||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||||
@@ -39,7 +39,7 @@ type CreateGroupRequest struct {
|
|||||||
type UpdateGroupRequest struct {
|
type UpdateGroupRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini"`
|
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||||
IsExclusive *bool `json:"is_exclusive"`
|
IsExclusive *bool `json:"is_exclusive"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
baseURL = getEnv("BASE_URL", "http://localhost:8080")
|
baseURL = getEnv("BASE_URL", "http://localhost:8080")
|
||||||
|
// ENDPOINT_PREFIX: 端点前缀,支持混合模式和非混合模式测试
|
||||||
|
// - "" (默认): 使用 /v1/messages, /v1beta/models(混合模式,可调度 antigravity 账户)
|
||||||
|
// - "/antigravity": 使用 /antigravity/v1/messages, /antigravity/v1beta/models(非混合模式,仅 antigravity 账户)
|
||||||
|
endpointPrefix = getEnv("ENDPOINT_PREFIX", "")
|
||||||
claudeAPIKey = "sk-8e572bc3b3de92ace4f41f4256c28600ca11805732a7b693b5c44741346bbbb3"
|
claudeAPIKey = "sk-8e572bc3b3de92ace4f41f4256c28600ca11805732a7b693b5c44741346bbbb3"
|
||||||
geminiAPIKey = "sk-5950197a2085b38bbe5a1b229cc02b8ece914963fc44cacc06d497ae8b87410f"
|
geminiAPIKey = "sk-5950197a2085b38bbe5a1b229cc02b8ece914963fc44cacc06d497ae8b87410f"
|
||||||
testInterval = 3 * time.Second // 测试间隔,防止限流
|
testInterval = 1 * time.Second // 测试间隔,防止限流
|
||||||
)
|
)
|
||||||
|
|
||||||
func getEnv(key, defaultVal string) string {
|
func getEnv(key, defaultVal string) string {
|
||||||
@@ -32,9 +36,9 @@ func getEnv(key, defaultVal string) string {
|
|||||||
// Claude 模型列表
|
// Claude 模型列表
|
||||||
var claudeModels = []string{
|
var claudeModels = []string{
|
||||||
// Opus 系列
|
// Opus 系列
|
||||||
"claude-opus-4-5-thinking", // 直接支持
|
"claude-opus-4-5-thinking", // 直接支持
|
||||||
"claude-opus-4", // 映射到 claude-opus-4-5-thinking
|
"claude-opus-4", // 映射到 claude-opus-4-5-thinking
|
||||||
"claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking
|
"claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking
|
||||||
// Sonnet 系列
|
// Sonnet 系列
|
||||||
"claude-sonnet-4-5", // 直接支持
|
"claude-sonnet-4-5", // 直接支持
|
||||||
"claude-sonnet-4-5-thinking", // 直接支持
|
"claude-sonnet-4-5-thinking", // 直接支持
|
||||||
@@ -56,13 +60,17 @@ var geminiModels = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
fmt.Printf("\n🚀 E2E Gateway Tests - %s\n\n", baseURL)
|
mode := "混合模式"
|
||||||
|
if endpointPrefix != "" {
|
||||||
|
mode = "Antigravity 模式"
|
||||||
|
}
|
||||||
|
fmt.Printf("\n🚀 E2E Gateway Tests - %s (prefix=%q, %s)\n\n", baseURL, endpointPrefix, mode)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClaudeModelsList 测试 GET /v1/models
|
// TestClaudeModelsList 测试 GET /v1/models
|
||||||
func TestClaudeModelsList(t *testing.T) {
|
func TestClaudeModelsList(t *testing.T) {
|
||||||
url := baseURL + "/v1/models"
|
url := baseURL + endpointPrefix + "/v1/models"
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
|
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
|
||||||
@@ -97,7 +105,7 @@ func TestClaudeModelsList(t *testing.T) {
|
|||||||
|
|
||||||
// TestGeminiModelsList 测试 GET /v1beta/models
|
// TestGeminiModelsList 测试 GET /v1beta/models
|
||||||
func TestGeminiModelsList(t *testing.T) {
|
func TestGeminiModelsList(t *testing.T) {
|
||||||
url := baseURL + "/v1beta/models"
|
url := baseURL + endpointPrefix + "/v1beta/models"
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+geminiAPIKey)
|
req.Header.Set("Authorization", "Bearer "+geminiAPIKey)
|
||||||
@@ -143,7 +151,7 @@ func TestClaudeMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testClaudeMessage(t *testing.T, model string, stream bool) {
|
func testClaudeMessage(t *testing.T, model string, stream bool) {
|
||||||
url := baseURL + "/v1/messages"
|
url := baseURL + endpointPrefix + "/v1/messages"
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -294,8 +302,8 @@ func testGeminiGenerate(t *testing.T, model string, stream bool) {
|
|||||||
func TestClaudeMessagesWithComplexTools(t *testing.T) {
|
func TestClaudeMessagesWithComplexTools(t *testing.T) {
|
||||||
// 测试模型列表(只测试几个代表性模型)
|
// 测试模型列表(只测试几个代表性模型)
|
||||||
models := []string{
|
models := []string{
|
||||||
"claude-opus-4-5-20251101", // Claude 模型
|
"claude-opus-4-5-20251101", // Claude 模型
|
||||||
"claude-haiku-4-5-20251001", // 映射到 Gemini
|
"claude-haiku-4-5-20251001", // 映射到 Gemini
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
@@ -309,7 +317,7 @@ func TestClaudeMessagesWithComplexTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testClaudeMessageWithTools(t *testing.T, model string) {
|
func testClaudeMessageWithTools(t *testing.T, model string) {
|
||||||
url := baseURL + "/v1/messages"
|
url := baseURL + endpointPrefix + "/v1/messages"
|
||||||
|
|
||||||
// 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具)
|
// 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具)
|
||||||
// 这些字段需要被 cleanJSONSchema 清理
|
// 这些字段需要被 cleanJSONSchema 清理
|
||||||
@@ -524,7 +532,7 @@ func TestClaudeMessagesWithThinkingAndTools(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testClaudeThinkingWithToolHistory(t *testing.T, model string) {
|
func testClaudeThinkingWithToolHistory(t *testing.T, model string) {
|
||||||
url := baseURL + "/v1/messages"
|
url := baseURL + endpointPrefix + "/v1/messages"
|
||||||
|
|
||||||
// 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话
|
// 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话
|
||||||
// 注意:tool_use 块故意不包含 signature,测试系统是否能正确添加 dummy signature
|
// 注意:tool_use 块故意不包含 signature,测试系统是否能正确添加 dummy signature
|
||||||
@@ -650,7 +658,7 @@ func TestClaudeMessagesWithNoSignature(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testClaudeWithNoSignature(t *testing.T, model string) {
|
func testClaudeWithNoSignature(t *testing.T, model string) {
|
||||||
url := baseURL + "/v1/messages"
|
url := baseURL + endpointPrefix + "/v1/messages"
|
||||||
|
|
||||||
// 模拟历史对话包含 thinking block 但没有 signature
|
// 模拟历史对话包含 thinking block 但没有 signature
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
@@ -730,104 +738,3 @@ func testClaudeWithNoSignature(t *testing.T, model string) {
|
|||||||
}
|
}
|
||||||
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
|
t.Logf("✅ 无 signature thinking 处理测试通过, id=%v", result["id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClaudeMessagesWithClaudeSignature 测试历史 thinking block 带有 Claude signature 的场景
|
|
||||||
// 验证:Claude 的 signature 格式与 Gemini 不兼容,发送到 Gemini 模型时应忽略(不传递)
|
|
||||||
func TestClaudeMessagesWithClaudeSignature(t *testing.T) {
|
|
||||||
models := []string{
|
|
||||||
"claude-haiku-4-5-20251001", // 映射到 gemini-3-flash
|
|
||||||
}
|
|
||||||
for i, model := range models {
|
|
||||||
if i > 0 {
|
|
||||||
time.Sleep(testInterval)
|
|
||||||
}
|
|
||||||
t.Run(model+"_带Claude_signature", func(t *testing.T) {
|
|
||||||
testClaudeWithClaudeSignature(t, model)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testClaudeWithClaudeSignature(t *testing.T, model string) {
|
|
||||||
url := baseURL + "/v1/messages"
|
|
||||||
|
|
||||||
// 模拟历史对话包含 thinking block 且带有 Claude 格式的 signature
|
|
||||||
// 这个 signature 是 Claude API 返回的格式,对 Gemini 无效
|
|
||||||
payload := map[string]any{
|
|
||||||
"model": model,
|
|
||||||
"max_tokens": 200,
|
|
||||||
"stream": false,
|
|
||||||
// 开启 thinking 模式
|
|
||||||
"thinking": map[string]any{
|
|
||||||
"type": "enabled",
|
|
||||||
"budget_tokens": 1024,
|
|
||||||
},
|
|
||||||
"messages": []any{
|
|
||||||
map[string]any{
|
|
||||||
"role": "user",
|
|
||||||
"content": "What is 2+2?",
|
|
||||||
},
|
|
||||||
// assistant 消息包含 thinking block 和 Claude 格式的 signature
|
|
||||||
map[string]any{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": []map[string]any{
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "Let me calculate 2+2. This is a simple arithmetic problem.",
|
|
||||||
// Claude API 返回的 signature 格式(base64 编码的加密数据)
|
|
||||||
"signature": "zbbJDG5qqgNXD/BVLwwxxT3gVaAY2hQ6CcB+hVLZWPi8r6vvlRBQKMfFPE3x5...",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": "2+2 equals 4.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
map[string]any{
|
|
||||||
"role": "user",
|
|
||||||
"content": "What is 3+3?",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+claudeAPIKey)
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("请求失败: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
// 400 错误说明 signature 未被正确忽略
|
|
||||||
if resp.StatusCode == 400 {
|
|
||||||
t.Fatalf("Claude signature 未被正确忽略,收到 400 错误: %s", string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 503 {
|
|
||||||
t.Skipf("账号暂时不可用 (503): %s", string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 {
|
|
||||||
t.Skipf("请求被限流 (429): %s", string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
t.Fatalf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
t.Fatalf("解析响应失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result["type"] != "message" {
|
|
||||||
t.Errorf("期望 type=message, 得到 %v", result["type"])
|
|
||||||
}
|
|
||||||
t.Logf("✅ Claude signature 忽略测试通过, id=%v", result["id"])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -346,3 +346,20 @@ func (a *Account) IsOpenAITokenExpired() bool {
|
|||||||
}
|
}
|
||||||
return time.Now().Add(60 * time.Second).After(*expiresAt)
|
return time.Now().Add(60 * time.Second).After(*expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMixedSchedulingEnabled 检查 antigravity 账户是否启用混合调度
|
||||||
|
// 启用后可参与 anthropic/gemini 分组的账户调度
|
||||||
|
func (a *Account) IsMixedSchedulingEnabled() bool {
|
||||||
|
if a.Platform != PlatformAntigravity {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Extra == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v, ok := a.Extra["mixed_scheduling"]; ok {
|
||||||
|
if enabled, ok := v.(bool); ok {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,25 +12,85 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockAccountRepoForMultiplatform 多平台测试用的 mock
|
// mockAccountRepoForPlatform 单平台测试用的 mock
|
||||||
type mockAccountRepoForMultiplatform struct {
|
type mockAccountRepoForPlatform struct {
|
||||||
accounts []Account
|
accounts []Account
|
||||||
accountsByID map[int64]*Account
|
accountsByID map[int64]*Account
|
||||||
listPlatformsFunc func(ctx context.Context, platforms []string) ([]Account, error)
|
listPlatformFunc func(ctx context.Context, platform string) ([]Account, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForMultiplatform) GetByID(ctx context.Context, id int64) (*Account, error) {
|
func (m *mockAccountRepoForPlatform) GetByID(ctx context.Context, id int64) (*Account, error) {
|
||||||
if acc, ok := m.accountsByID[id]; ok {
|
if acc, ok := m.accountsByID[id]; ok {
|
||||||
return acc, nil
|
return acc, nil
|
||||||
}
|
}
|
||||||
return nil, errors.New("account not found")
|
return nil, errors.New("account not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
func (m *mockAccountRepoForPlatform) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||||
if m.listPlatformsFunc != nil {
|
if m.listPlatformFunc != nil {
|
||||||
return m.listPlatformsFunc(ctx, platforms)
|
return m.listPlatformFunc(ctx, platform)
|
||||||
}
|
}
|
||||||
// 过滤符合平台的账户
|
var result []Account
|
||||||
|
for _, acc := range m.accounts {
|
||||||
|
if acc.Platform == platform && acc.IsSchedulable() {
|
||||||
|
result = append(result, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAccountRepoForPlatform) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
||||||
|
return m.ListSchedulableByPlatform(ctx, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub methods to implement AccountRepository interface
|
||||||
|
func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Account) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) Update(ctx context.Context, account *Account) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) Delete(ctx context.Context, id int64) error { return nil }
|
||||||
|
func (m *mockAccountRepoForPlatform) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListActive(ctx context.Context) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) UpdateLastUsed(ctx context.Context, id int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) SetError(ctx context.Context, id int64, errorMsg string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListSchedulable(ctx context.Context) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||||
var result []Account
|
var result []Account
|
||||||
platformSet := make(map[string]bool)
|
platformSet := make(map[string]bool)
|
||||||
for _, p := range platforms {
|
for _, p := range platforms {
|
||||||
@@ -43,99 +103,44 @@ func (m *mockAccountRepoForMultiplatform) ListSchedulableByPlatforms(ctx context
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
|
||||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||||
// Stub methods to implement AccountRepository interface
|
|
||||||
func (m *mockAccountRepoForMultiplatform) Create(ctx context.Context, account *Account) error {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForMultiplatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) {
|
func (m *mockAccountRepoForPlatform) SetOverloaded(ctx context.Context, id int64, until time.Time) error {
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) Update(ctx context.Context, account *Account) error {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForMultiplatform) Delete(ctx context.Context, id int64) error { return nil }
|
func (m *mockAccountRepoForPlatform) ClearRateLimit(ctx context.Context, id int64) error {
|
||||||
func (m *mockAccountRepoForMultiplatform) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
|
||||||
return nil, nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListActive(ctx context.Context) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) UpdateLastUsed(ctx context.Context, id int64) error {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForMultiplatform) BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
|
func (m *mockAccountRepoForPlatform) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForMultiplatform) SetError(ctx context.Context, id int64, errorMsg string) error {
|
func (m *mockAccountRepoForPlatform) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForMultiplatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) error {
|
func (m *mockAccountRepoForPlatform) BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulable(ctx context.Context) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) SetOverloaded(ctx context.Context, id int64, until time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) ClearRateLimit(ctx context.Context, id int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (m *mockAccountRepoForMultiplatform) BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify interface implementation
|
// Verify interface implementation
|
||||||
var _ AccountRepository = (*mockAccountRepoForMultiplatform)(nil)
|
var _ AccountRepository = (*mockAccountRepoForPlatform)(nil)
|
||||||
|
|
||||||
// mockGatewayCacheForMultiplatform 多平台测试用的 cache mock
|
// mockGatewayCacheForPlatform 单平台测试用的 cache mock
|
||||||
type mockGatewayCacheForMultiplatform struct {
|
type mockGatewayCacheForPlatform struct {
|
||||||
sessionBindings map[string]int64
|
sessionBindings map[string]int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockGatewayCacheForMultiplatform) GetSessionAccountID(ctx context.Context, sessionHash string) (int64, error) {
|
func (m *mockGatewayCacheForPlatform) GetSessionAccountID(ctx context.Context, sessionHash string) (int64, error) {
|
||||||
if id, ok := m.sessionBindings[sessionHash]; ok {
|
if id, ok := m.sessionBindings[sessionHash]; ok {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
return 0, errors.New("not found")
|
return 0, errors.New("not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockGatewayCacheForMultiplatform) SetSessionAccountID(ctx context.Context, sessionHash string, accountID int64, ttl time.Duration) error {
|
func (m *mockGatewayCacheForPlatform) SetSessionAccountID(ctx context.Context, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||||
if m.sessionBindings == nil {
|
if m.sessionBindings == nil {
|
||||||
m.sessionBindings = make(map[string]int64)
|
m.sessionBindings = make(map[string]int64)
|
||||||
}
|
}
|
||||||
@@ -143,7 +148,7 @@ func (m *mockGatewayCacheForMultiplatform) SetSessionAccountID(ctx context.Conte
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockGatewayCacheForMultiplatform) RefreshSessionTTL(ctx context.Context, sessionHash string, ttl time.Duration) error {
|
func (m *mockGatewayCacheForPlatform) RefreshSessionTTL(ctx context.Context, sessionHash string, ttl time.Duration) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,13 +156,15 @@ func ptr[T any](v T) *T {
|
|||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_OnlyAnthropic(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_Anthropic 测试 anthropic 单平台选择
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 3, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被隔离
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -165,80 +172,27 @@ func TestGatewayService_SelectAccountForModelWithExclusions_OnlyAnthropic(t *tes
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(1), acc.ID, "应选择优先级最高的账户")
|
require.Equal(t, int64(1), acc.ID, "应选择优先级最高的 anthropic 账户")
|
||||||
|
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_OnlyAntigravity(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被隔离
|
||||||
},
|
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
|
||||||
|
|
||||||
svc := &GatewayService{
|
|
||||||
accountRepo: repo,
|
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
require.Equal(t, int64(1), acc.ID)
|
|
||||||
require.Equal(t, PlatformAntigravity, acc.Platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_MixedPlatforms_SamePriority(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-1 * time.Hour))},
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-2 * time.Hour))},
|
|
||||||
},
|
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
|
||||||
|
|
||||||
svc := &GatewayService{
|
|
||||||
accountRepo: repo,
|
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
require.Equal(t, int64(2), acc.ID, "应选择最久未用的账户(Antigravity)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_MixedPlatforms_DiffPriority(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
@@ -247,32 +201,29 @@ func TestGatewayService_SelectAccountForModelWithExclusions_MixedPlatforms_DiffP
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAntigravity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(2), acc.ID, "应选择优先级更高的账户(Antigravity, priority=1)")
|
require.Equal(t, int64(2), acc.ID)
|
||||||
|
require.Equal(t, PlatformAntigravity, acc.Platform, "应只返回 antigravity 平台账户")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_ModelNotSupported(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_PriorityAndLastUsed 测试优先级和最后使用时间
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_PriorityAndLastUsed(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
// Anthropic 账户配置了模型映射,只支持 other-model
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-1 * time.Hour))},
|
||||||
// 注意:model_mapping 需要是 map[string]any 格式
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-2 * time.Hour))},
|
||||||
{
|
|
||||||
ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true,
|
|
||||||
Credentials: map[string]any{"model_mapping": map[string]any{"other-model": "x"}},
|
|
||||||
},
|
|
||||||
// Antigravity 账户支持所有 claude 模型
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -280,47 +231,49 @@ func TestGatewayService_SelectAccountForModelWithExclusions_ModelNotSupported(t
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(2), acc.ID, "Anthropic 不支持该模型,应选择 Antigravity")
|
require.Equal(t, int64(2), acc.ID, "同优先级应选择最久未用的账户")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_NoAvailableAccounts(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_NoAvailableAccounts 测试无可用账户
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_NoAvailableAccounts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{},
|
accounts: []Account{},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Nil(t, acc)
|
require.Nil(t, acc)
|
||||||
require.Contains(t, err.Error(), "no available accounts")
|
require.Contains(t, err.Error(), "no available accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_AllExcluded(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_AllExcluded 测试所有账户被排除
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_AllExcluded(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -328,7 +281,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_AllExcluded(t *testi
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
@@ -336,12 +289,13 @@ func TestGatewayService_SelectAccountForModelWithExclusions_AllExcluded(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
excludedIDs := map[int64]struct{}{1: {}, 2: {}}
|
excludedIDs := map[int64]struct{}{1: {}, 2: {}}
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", excludedIDs, PlatformAnthropic)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Nil(t, acc)
|
require.Nil(t, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_Schedulability 测试账户可调度性检查
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_Schedulability(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -354,7 +308,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
name: "过载账户被跳过",
|
name: "过载账户被跳过",
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, OverloadUntil: ptr(now.Add(1 * time.Hour))},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, OverloadUntil: ptr(now.Add(1 * time.Hour))},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
expectedID: 2,
|
expectedID: 2,
|
||||||
},
|
},
|
||||||
@@ -362,7 +316,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
name: "限流账户被跳过",
|
name: "限流账户被跳过",
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, RateLimitResetAt: ptr(now.Add(1 * time.Hour))},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, RateLimitResetAt: ptr(now.Add(1 * time.Hour))},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
expectedID: 2,
|
expectedID: 2,
|
||||||
},
|
},
|
||||||
@@ -370,7 +324,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
name: "非active账户被跳过",
|
name: "非active账户被跳过",
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: "error", Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: "error", Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
expectedID: 2,
|
expectedID: 2,
|
||||||
},
|
},
|
||||||
@@ -378,7 +332,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
name: "schedulable=false被跳过",
|
name: "schedulable=false被跳过",
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: false},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: false},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
expectedID: 2,
|
expectedID: 2,
|
||||||
},
|
},
|
||||||
@@ -386,7 +340,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
name: "过期的过载账户可调度",
|
name: "过期的过载账户可调度",
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, OverloadUntil: ptr(now.Add(-1 * time.Hour))},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, OverloadUntil: ptr(now.Add(-1 * time.Hour))},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
expectedID: 1,
|
expectedID: 1,
|
||||||
},
|
},
|
||||||
@@ -394,7 +348,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: tt.accounts,
|
accounts: tt.accounts,
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -402,14 +356,14 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{}
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
svc := &GatewayService{
|
svc := &GatewayService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, tt.expectedID, acc.ID)
|
require.Equal(t, tt.expectedID, acc.ID)
|
||||||
@@ -417,14 +371,15 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *testing.T) {
|
// TestGatewayService_SelectAccountForModelWithPlatform_StickySession 测试粘性会话
|
||||||
|
func TestGatewayService_SelectAccountForModelWithPlatform_StickySession(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("粘性会话命中", func(t *testing.T) {
|
t.Run("粘性会话命中-同平台", func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -432,7 +387,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{
|
cache := &mockGatewayCacheForPlatform{
|
||||||
sessionBindings: map[string]int64{"session-123": 1},
|
sessionBindings: map[string]int64{"session-123": 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,17 +396,17 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(1), acc.ID, "应返回粘性会话绑定的账户")
|
require.Equal(t, int64(1), acc.ID, "应返回粘性会话绑定的账户")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("粘性会话账户被排除-降级选择", func(t *testing.T) {
|
t.Run("粘性会话不匹配平台-降级选择", func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true}, // 粘性会话绑定但平台不匹配
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -459,7 +414,36 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{
|
cache := &mockGatewayCacheForPlatform{
|
||||||
|
sessionBindings: map[string]int64{"session-123": 1}, // 绑定 antigravity 账户
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求 anthropic 平台,但粘性会话绑定的是 antigravity 账户
|
||||||
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(2), acc.ID, "粘性会话账户平台不匹配,应降级选择同平台账户")
|
||||||
|
require.Equal(t, PlatformAnthropic, acc.Platform)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("粘性会话账户被排除-降级选择", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{
|
||||||
sessionBindings: map[string]int64{"session-123": 1},
|
sessionBindings: map[string]int64{"session-123": 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,17 +453,17 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
excludedIDs := map[int64]struct{}{1: {}}
|
excludedIDs := map[int64]struct{}{1: {}}
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", excludedIDs, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", excludedIDs, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(2), acc.ID, "粘性会话账户被排除,应选择其他账户")
|
require.Equal(t, int64(2), acc.ID, "粘性会话账户被排除,应选择其他账户")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("粘性会话账户不可调度-降级选择", func(t *testing.T) {
|
t.Run("粘性会话账户不可调度-降级选择", func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForMultiplatform{
|
repo := &mockAccountRepoForPlatform{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: "error", Schedulable: true},
|
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: "error", Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -487,7 +471,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForMultiplatform{
|
cache := &mockGatewayCacheForPlatform{
|
||||||
sessionBindings: map[string]int64{"session-123": 1},
|
sessionBindings: map[string]int64{"session-123": 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +480,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes
|
|||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.selectAccountForModelWithPlatforms(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, []string{PlatformAnthropic, PlatformAntigravity})
|
acc, err := svc.selectAccountForModelWithPlatform(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(2), acc.ID, "粘性会话账户不可调度,应选择其他账户")
|
require.Equal(t, int64(2), acc.ID, "粘性会话账户不可调度,应选择其他账户")
|
||||||
@@ -563,3 +547,209 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGatewayService_selectAccountWithMixedScheduling 测试混合调度
|
||||||
|
func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("混合调度-包含启用mixed_scheduling的antigravity账户", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true, Extra: map[string]any{"mixed_scheduling": true}},
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(2), acc.ID, "应选择优先级最高的账户(包含启用混合调度的antigravity)")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("混合调度-过滤未启用mixed_scheduling的antigravity账户", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, // 未启用 mixed_scheduling
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(1), acc.ID, "未启用mixed_scheduling的antigravity账户应被过滤")
|
||||||
|
require.Equal(t, PlatformAnthropic, acc.Platform)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("混合调度-粘性会话命中启用mixed_scheduling的antigravity账户", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true, Extra: map[string]any{"mixed_scheduling": true}},
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{
|
||||||
|
sessionBindings: map[string]int64{"session-123": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(2), acc.ID, "应返回粘性会话绑定的启用mixed_scheduling的antigravity账户")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("混合调度-粘性会话命中未启用mixed_scheduling的antigravity账户-降级选择", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 2, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true}, // 未启用 mixed_scheduling
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{
|
||||||
|
sessionBindings: map[string]int64{"session-123": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "session-123", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(1), acc.ID, "粘性会话绑定的账户未启用mixed_scheduling,应降级选择anthropic账户")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("混合调度-仅有启用mixed_scheduling的antigravity账户", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true, Extra: map[string]any{"mixed_scheduling": true}},
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(1), acc.ID)
|
||||||
|
require.Equal(t, PlatformAntigravity, acc.Platform)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("混合调度-无可用账户", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForPlatform{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, // 未启用 mixed_scheduling
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForPlatform{}
|
||||||
|
|
||||||
|
svc := &GatewayService{
|
||||||
|
accountRepo: repo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, acc)
|
||||||
|
require.Contains(t, err.Error(), "no available accounts")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAccount_IsMixedSchedulingEnabled 测试混合调度开关检查
|
||||||
|
func TestAccount_IsMixedSchedulingEnabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
account Account
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "非antigravity平台-返回false",
|
||||||
|
account: Account{Platform: PlatformAnthropic},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity平台-无extra-返回false",
|
||||||
|
account: Account{Platform: PlatformAntigravity},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity平台-extra无mixed_scheduling-返回false",
|
||||||
|
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity平台-mixed_scheduling=false-返回false",
|
||||||
|
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": false}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity平台-mixed_scheduling=true-返回true",
|
||||||
|
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity平台-mixed_scheduling非bool类型-返回false",
|
||||||
|
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": "true"}},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tt.account.IsMixedSchedulingEnabled()
|
||||||
|
require.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func (e *UpstreamFailoverError) Error() string {
|
|||||||
// GatewayService handles API gateway operations
|
// GatewayService handles API gateway operations
|
||||||
type GatewayService struct {
|
type GatewayService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
|
groupRepo GroupRepository
|
||||||
usageLogRepo UsageLogRepository
|
usageLogRepo UsageLogRepository
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
@@ -109,6 +110,7 @@ type GatewayService struct {
|
|||||||
// NewGatewayService creates a new GatewayService
|
// NewGatewayService creates a new GatewayService
|
||||||
func NewGatewayService(
|
func NewGatewayService(
|
||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
|
groupRepo GroupRepository,
|
||||||
usageLogRepo UsageLogRepository,
|
usageLogRepo UsageLogRepository,
|
||||||
userRepo UserRepository,
|
userRepo UserRepository,
|
||||||
userSubRepo UserSubscriptionRepository,
|
userSubRepo UserSubscriptionRepository,
|
||||||
@@ -123,6 +125,7 @@ func NewGatewayService(
|
|||||||
) *GatewayService {
|
) *GatewayService {
|
||||||
return &GatewayService{
|
return &GatewayService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
usageLogRepo: usageLogRepo,
|
usageLogRepo: usageLogRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
userSubRepo: userSubRepo,
|
userSubRepo: userSubRepo,
|
||||||
@@ -291,23 +294,38 @@ func (s *GatewayService) SelectAccountForModel(ctx context.Context, groupID *int
|
|||||||
|
|
||||||
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
||||||
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
// 使用多平台账户选择,包含 anthropic 和 antigravity 平台
|
// 根据分组 platform 决定查询哪种账号
|
||||||
platforms := []string{PlatformAnthropic, PlatformAntigravity}
|
var platform string
|
||||||
return s.selectAccountForModelWithPlatforms(ctx, groupID, sessionHash, requestedModel, excludedIDs, platforms)
|
if groupID != nil {
|
||||||
|
group, err := s.groupRepo.GetByID(ctx, *groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get group failed: %w", err)
|
||||||
|
}
|
||||||
|
platform = group.Platform
|
||||||
|
} else {
|
||||||
|
// 无分组时只使用原生 anthropic 平台
|
||||||
|
platform = PlatformAnthropic
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
|
||||||
|
if platform == PlatformAnthropic || platform == PlatformGemini {
|
||||||
|
return s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// antigravity 分组或无分组使用单平台选择
|
||||||
|
return s.selectAccountForModelWithPlatform(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectAccountForModelWithPlatforms 选择多平台账户
|
// selectAccountForModelWithPlatform 选择单平台账户(完全隔离)
|
||||||
func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platforms []string) (*Account, error) {
|
func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platform string) (*Account, error) {
|
||||||
// 1. 查询粘性会话
|
// 1. 查询粘性会话
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// 使用IsSchedulable代替IsActive,确保限流/过载账号不会被选中
|
// 检查账号平台是否匹配(确保粘性会话不会跨平台)
|
||||||
// 同时检查模型支持(根据平台类型分别处理)
|
if err == nil && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
|
||||||
// 续期粘性会话
|
|
||||||
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||||
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||||
}
|
}
|
||||||
@@ -317,13 +335,13 @@ func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取可调度账号列表(排除限流和过载的账号,支持多平台)
|
// 2. 获取可调度账号列表(单平台)
|
||||||
var accounts []Account
|
var accounts []Account
|
||||||
var err error
|
var err error
|
||||||
if groupID != nil {
|
if groupID != nil {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms)
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform)
|
||||||
} else {
|
} else {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query accounts failed: %w", err)
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
||||||
@@ -336,7 +354,6 @@ func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context,
|
|||||||
if _, excluded := excludedIDs[acc.ID]; excluded {
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 检查模型支持(根据平台类型分别处理)
|
|
||||||
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -344,11 +361,98 @@ func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context,
|
|||||||
selected = acc
|
selected = acc
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 优先选择priority值更小的(priority值越小优先级越高)
|
|
||||||
if acc.Priority < selected.Priority {
|
if acc.Priority < selected.Priority {
|
||||||
selected = acc
|
selected = acc
|
||||||
} else if acc.Priority == selected.Priority {
|
} else if acc.Priority == selected.Priority {
|
||||||
// 优先级相同时,选最久未用的
|
switch {
|
||||||
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
||||||
|
selected = acc
|
||||||
|
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
|
||||||
|
// keep selected (never used is preferred)
|
||||||
|
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
|
||||||
|
// keep selected (both never used)
|
||||||
|
default:
|
||||||
|
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
|
||||||
|
selected = acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil {
|
||||||
|
if requestedModel != "" {
|
||||||
|
return nil, fmt.Errorf("no available accounts supporting model: %s", requestedModel)
|
||||||
|
}
|
||||||
|
return nil, errors.New("no available accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 建立粘性绑定
|
||||||
|
if sessionHash != "" {
|
||||||
|
if err := s.cache.SetSessionAccountID(ctx, sessionHash, selected.ID, stickySessionTTL); err != nil {
|
||||||
|
log.Printf("set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAccountWithMixedScheduling 选择账户(支持混合调度)
|
||||||
|
// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
|
||||||
|
func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, nativePlatform string) (*Account, error) {
|
||||||
|
platforms := []string{nativePlatform, PlatformAntigravity}
|
||||||
|
|
||||||
|
// 1. 查询粘性会话
|
||||||
|
if sessionHash != "" {
|
||||||
|
accountID, err := s.cache.GetSessionAccountID(ctx, sessionHash)
|
||||||
|
if err == nil && accountID > 0 {
|
||||||
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
|
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
|
||||||
|
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
|
if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
|
||||||
|
if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil {
|
||||||
|
log.Printf("refresh session ttl failed: session=%s err=%v", sessionHash, err)
|
||||||
|
}
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取可调度账号列表
|
||||||
|
var accounts []Account
|
||||||
|
var err error
|
||||||
|
if groupID != nil {
|
||||||
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms)
|
||||||
|
} else {
|
||||||
|
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
|
||||||
|
var selected *Account
|
||||||
|
for i := range accounts {
|
||||||
|
acc := &accounts[i]
|
||||||
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
|
||||||
|
if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
selected = acc
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if acc.Priority < selected.Priority {
|
||||||
|
selected = acc
|
||||||
|
} else if acc.Priority == selected.Priority {
|
||||||
switch {
|
switch {
|
||||||
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
|
||||||
selected = acc
|
selected = acc
|
||||||
|
|||||||
@@ -33,16 +33,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeminiMessagesCompatService struct {
|
type GeminiMessagesCompatService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
cache GatewayCache
|
groupRepo GroupRepository
|
||||||
tokenProvider *GeminiTokenProvider
|
cache GatewayCache
|
||||||
rateLimitService *RateLimitService
|
tokenProvider *GeminiTokenProvider
|
||||||
httpUpstream HTTPUpstream
|
rateLimitService *RateLimitService
|
||||||
|
httpUpstream HTTPUpstream
|
||||||
antigravityGatewayService *AntigravityGatewayService
|
antigravityGatewayService *AntigravityGatewayService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGeminiMessagesCompatService(
|
func NewGeminiMessagesCompatService(
|
||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
|
groupRepo GroupRepository,
|
||||||
cache GatewayCache,
|
cache GatewayCache,
|
||||||
tokenProvider *GeminiTokenProvider,
|
tokenProvider *GeminiTokenProvider,
|
||||||
rateLimitService *RateLimitService,
|
rateLimitService *RateLimitService,
|
||||||
@@ -50,11 +52,12 @@ func NewGeminiMessagesCompatService(
|
|||||||
antigravityGatewayService *AntigravityGatewayService,
|
antigravityGatewayService *AntigravityGatewayService,
|
||||||
) *GeminiMessagesCompatService {
|
) *GeminiMessagesCompatService {
|
||||||
return &GeminiMessagesCompatService{
|
return &GeminiMessagesCompatService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
cache: cache,
|
groupRepo: groupRepo,
|
||||||
tokenProvider: tokenProvider,
|
cache: cache,
|
||||||
rateLimitService: rateLimitService,
|
tokenProvider: tokenProvider,
|
||||||
httpUpstream: httpUpstream,
|
rateLimitService: rateLimitService,
|
||||||
|
httpUpstream: httpUpstream,
|
||||||
antigravityGatewayService: antigravityGatewayService,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,30 +72,59 @@ func (s *GeminiMessagesCompatService) SelectAccountForModel(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
|
// 根据分组 platform 决定查询哪种账号
|
||||||
|
var platform string
|
||||||
|
if groupID != nil {
|
||||||
|
group, err := s.groupRepo.GetByID(ctx, *groupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get group failed: %w", err)
|
||||||
|
}
|
||||||
|
platform = group.Platform
|
||||||
|
} else {
|
||||||
|
// 无分组时只使用原生 gemini 平台
|
||||||
|
platform = PlatformGemini
|
||||||
|
}
|
||||||
|
|
||||||
|
// gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
|
||||||
|
useMixedScheduling := platform == PlatformGemini
|
||||||
|
var queryPlatforms []string
|
||||||
|
if useMixedScheduling {
|
||||||
|
queryPlatforms = []string{PlatformGemini, PlatformAntigravity}
|
||||||
|
} else {
|
||||||
|
queryPlatforms = []string{platform}
|
||||||
|
}
|
||||||
|
|
||||||
cacheKey := "gemini:" + sessionHash
|
cacheKey := "gemini:" + sessionHash
|
||||||
platforms := []string{PlatformGemini, PlatformAntigravity}
|
|
||||||
|
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey)
|
accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey)
|
||||||
if err == nil && accountID > 0 {
|
if err == nil && accountID > 0 {
|
||||||
if _, excluded := excludedIDs[accountID]; !excluded {
|
if _, excluded := excludedIDs[accountID]; !excluded {
|
||||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
// 支持 gemini 和 antigravity 平台的粘性会话
|
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
|
||||||
if err == nil && account.IsSchedulable() && (account.Platform == PlatformGemini || account.Platform == PlatformAntigravity) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) {
|
||||||
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
|
valid := false
|
||||||
return account, nil
|
if account.Platform == platform {
|
||||||
|
valid = true
|
||||||
|
} else if useMixedScheduling && account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled() {
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
_ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL)
|
||||||
|
return account, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同时查询 gemini 和 antigravity 平台的可调度账户
|
// 查询可调度账户
|
||||||
var accounts []Account
|
var accounts []Account
|
||||||
var err error
|
var err error
|
||||||
if groupID != nil {
|
if groupID != nil {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms)
|
accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, queryPlatforms)
|
||||||
} else {
|
} else {
|
||||||
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms)
|
accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, queryPlatforms)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query accounts failed: %w", err)
|
return nil, fmt.Errorf("query accounts failed: %w", err)
|
||||||
@@ -104,7 +136,11 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
|
|||||||
if _, excluded := excludedIDs[acc.ID]; excluded {
|
if _, excluded := excludedIDs[acc.ID]; excluded {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 根据平台类型分别检查模型支持
|
// 混合调度模式下:原生平台直接通过,antigravity 需要启用 mixed_scheduling
|
||||||
|
// 非混合调度模式(antigravity 分组):不需要过滤
|
||||||
|
if useMixedScheduling && acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -135,9 +171,9 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
|
|||||||
|
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
if requestedModel != "" {
|
if requestedModel != "" {
|
||||||
return nil, fmt.Errorf("no available Gemini/Antigravity accounts supporting model: %s", requestedModel)
|
return nil, fmt.Errorf("no available Gemini accounts supporting model: %s", requestedModel)
|
||||||
}
|
}
|
||||||
return nil, errors.New("no available Gemini/Antigravity accounts")
|
return nil, errors.New("no available Gemini accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionHash != "" {
|
if sessionHash != "" {
|
||||||
|
|||||||
@@ -25,22 +25,19 @@ func (m *mockAccountRepoForGemini) GetByID(ctx context.Context, id int64) (*Acco
|
|||||||
return nil, errors.New("account not found")
|
return nil, errors.New("account not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForGemini) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||||
platformSet := make(map[string]bool)
|
|
||||||
for _, p := range platforms {
|
|
||||||
platformSet[p] = true
|
|
||||||
}
|
|
||||||
var result []Account
|
var result []Account
|
||||||
for _, acc := range m.accounts {
|
for _, acc := range m.accounts {
|
||||||
if platformSet[acc.Platform] && acc.IsSchedulable() {
|
if acc.Platform == platform && acc.IsSchedulable() {
|
||||||
result = append(result, acc)
|
result = append(result, acc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
||||||
return m.ListSchedulableByPlatforms(ctx, platforms)
|
// 测试时不区分 groupID,直接按 platform 过滤
|
||||||
|
return m.ListSchedulableByPlatform(ctx, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stub methods to implement AccountRepository interface
|
// Stub methods to implement AccountRepository interface
|
||||||
@@ -82,18 +79,21 @@ func (m *mockAccountRepoForGemini) ListSchedulable(ctx context.Context) ([]Accou
|
|||||||
func (m *mockAccountRepoForGemini) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForGemini) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) {
|
||||||
var result []Account
|
var result []Account
|
||||||
|
platformSet := make(map[string]bool)
|
||||||
|
for _, p := range platforms {
|
||||||
|
platformSet[p] = true
|
||||||
|
}
|
||||||
for _, acc := range m.accounts {
|
for _, acc := range m.accounts {
|
||||||
if acc.Platform == platform && acc.IsSchedulable() {
|
if platformSet[acc.Platform] && acc.IsSchedulable() {
|
||||||
result = append(result, acc)
|
result = append(result, acc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) {
|
||||||
// 测试时不区分 groupID,直接按 platform 过滤
|
return m.ListSchedulableByPlatforms(ctx, platforms)
|
||||||
return m.ListSchedulableByPlatform(ctx, platform)
|
|
||||||
}
|
}
|
||||||
func (m *mockAccountRepoForGemini) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
func (m *mockAccountRepoForGemini) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -115,6 +115,47 @@ func (m *mockAccountRepoForGemini) BulkUpdate(ctx context.Context, ids []int64,
|
|||||||
// Verify interface implementation
|
// Verify interface implementation
|
||||||
var _ AccountRepository = (*mockAccountRepoForGemini)(nil)
|
var _ AccountRepository = (*mockAccountRepoForGemini)(nil)
|
||||||
|
|
||||||
|
// mockGroupRepoForGemini Gemini 测试用的 group repo mock
|
||||||
|
type mockGroupRepoForGemini struct {
|
||||||
|
groups map[int64]*Group
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockGroupRepoForGemini) GetByID(ctx context.Context, id int64) (*Group, error) {
|
||||||
|
if g, ok := m.groups[id]; ok {
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("group not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub methods to implement GroupRepository interface
|
||||||
|
func (m *mockGroupRepoForGemini) Create(ctx context.Context, group *Group) error { return nil }
|
||||||
|
func (m *mockGroupRepoForGemini) Update(ctx context.Context, group *Group) error { return nil }
|
||||||
|
func (m *mockGroupRepoForGemini) Delete(ctx context.Context, id int64) error { return nil }
|
||||||
|
func (m *mockGroupRepoForGemini) DeleteCascade(ctx context.Context, id int64) ([]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) ListActive(ctx context.Context) ([]Group, error) { return nil, nil }
|
||||||
|
func (m *mockGroupRepoForGemini) ListActiveByPlatform(ctx context.Context, platform string) ([]Group, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) ExistsByName(ctx context.Context, name string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) GetAccountCount(ctx context.Context, groupID int64) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (m *mockGroupRepoForGemini) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ GroupRepository = (*mockGroupRepoForGemini)(nil)
|
||||||
|
|
||||||
// mockGatewayCacheForGemini Gemini 测试用的 cache mock
|
// mockGatewayCacheForGemini Gemini 测试用的 cache mock
|
||||||
type mockGatewayCacheForGemini struct {
|
type mockGatewayCacheForGemini struct {
|
||||||
sessionBindings map[string]int64
|
sessionBindings map[string]int64
|
||||||
@@ -139,13 +180,15 @@ func (m *mockGatewayCacheForGemini) RefreshSessionTTL(ctx context.Context, sessi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyGemini(t *testing.T) {
|
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择
|
||||||
|
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForGemini{
|
repo := &mockAccountRepoForGemini{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
|
{ID: 3, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被隔离
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -154,24 +197,30 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyGem
|
|||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
cache := &mockGatewayCacheForGemini{}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 无分组时使用 gemini 平台
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
require.Equal(t, int64(1), acc.ID, "应选择优先级最高的账户")
|
require.Equal(t, int64(1), acc.ID, "应选择优先级最高的 gemini 账户")
|
||||||
|
require.Equal(t, PlatformGemini, acc.Platform, "无分组时应只返回 gemini 平台账户")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyAntigravity(t *testing.T) {
|
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup 测试 antigravity 分组
|
||||||
|
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_AntigravityGroup(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
repo := &mockAccountRepoForGemini{
|
repo := &mockAccountRepoForGemini{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被隔离
|
||||||
|
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被选择
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -180,76 +229,27 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyAnt
|
|||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
cache := &mockGatewayCacheForGemini{}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{
|
||||||
svc := &GeminiMessagesCompatService{
|
groups: map[int64]*Group{
|
||||||
accountRepo: repo,
|
1: {ID: 1, Platform: PlatformAntigravity},
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
require.Equal(t, int64(1), acc.ID)
|
|
||||||
require.Equal(t, PlatformAntigravity, acc.Platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_ExcludesAnthropic(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true},
|
|
||||||
{ID: 2, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
|
||||||
{ID: 3, Platform: PlatformAntigravity, Priority: 3, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
}
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
groupID := int64(1)
|
||||||
|
acc, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "gemini-2.5-flash", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
// Anthropic 不在 [gemini, antigravity] 平台列表中,应被过滤
|
require.Equal(t, int64(2), acc.ID)
|
||||||
require.Equal(t, int64(2), acc.ID, "Anthropic 平台应被排除,选择 Gemini")
|
require.Equal(t, PlatformAntigravity, acc.Platform, "antigravity 分组应只返回 antigravity 账户")
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_MixedPlatforms_SamePriority(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-1 * time.Hour))},
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-2 * time.Hour))},
|
|
||||||
},
|
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
|
||||||
accountRepo: repo,
|
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
require.Equal(t, int64(2), acc.ID, "应选择最久未用的账户(Antigravity)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred 测试 OAuth 优先
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred(t *testing.T) {
|
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -265,9 +265,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
cache := &mockGatewayCacheForGemini{}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,33 +280,7 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPr
|
|||||||
require.Equal(t, AccountTypeOAuth, acc.Type)
|
require.Equal(t, AccountTypeOAuth, acc.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred_MixedPlatforms(t *testing.T) {
|
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvailableAccounts 测试无可用账户
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformGemini, Type: AccountTypeApiKey, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: nil},
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Type: AccountTypeOAuth, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: nil},
|
|
||||||
},
|
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
|
||||||
accountRepo: repo,
|
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
require.Equal(t, int64(2), acc.ID, "跨平台时,同样优先选择 OAuth 账户")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvailableAccounts(t *testing.T) {
|
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvailableAccounts(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -314,26 +290,29 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvail
|
|||||||
}
|
}
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{}
|
cache := &mockGatewayCacheForGemini{}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Nil(t, acc)
|
require.Nil(t, acc)
|
||||||
require.Contains(t, err.Error(), "no available Gemini/Antigravity accounts")
|
require.Contains(t, err.Error(), "no available")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickySession 测试粘性会话
|
||||||
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickySession(t *testing.T) {
|
func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickySession(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("粘性会话命中-使用gemini前缀缓存键", func(t *testing.T) {
|
t.Run("粘性会话命中-同平台", func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForGemini{
|
repo := &mockAccountRepoForGemini{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -345,9 +324,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS
|
|||||||
cache := &mockGatewayCacheForGemini{
|
cache := &mockGatewayCacheForGemini{
|
||||||
sessionBindings: map[string]int64{"gemini:session-123": 1},
|
sessionBindings: map[string]int64{"gemini:session-123": 1},
|
||||||
}
|
}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,11 +338,42 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS
|
|||||||
require.Equal(t, int64(1), acc.ID, "应返回粘性会话绑定的账户")
|
require.Equal(t, int64(1), acc.ID, "应返回粘性会话绑定的账户")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("粘性会话平台不匹配-降级选择", func(t *testing.T) {
|
||||||
|
repo := &mockAccountRepoForGemini{
|
||||||
|
accounts: []Account{
|
||||||
|
{ID: 1, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true}, // 粘性会话绑定
|
||||||
|
{ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
|
},
|
||||||
|
accountsByID: map[int64]*Account{},
|
||||||
|
}
|
||||||
|
for i := range repo.accounts {
|
||||||
|
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &mockGatewayCacheForGemini{
|
||||||
|
sessionBindings: map[string]int64{"gemini:session-123": 1}, // 绑定 antigravity 账户
|
||||||
|
}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
|
svc := &GeminiMessagesCompatService{
|
||||||
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无分组时使用 gemini 平台,粘性会话绑定的 antigravity 账户平台不匹配
|
||||||
|
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "session-123", "gemini-2.5-flash", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, acc)
|
||||||
|
require.Equal(t, int64(2), acc.ID, "粘性会话账户平台不匹配,应降级选择 gemini 账户")
|
||||||
|
require.Equal(t, PlatformGemini, acc.Platform)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("粘性会话不命中无前缀缓存键", func(t *testing.T) {
|
t.Run("粘性会话不命中无前缀缓存键", func(t *testing.T) {
|
||||||
repo := &mockAccountRepoForGemini{
|
repo := &mockAccountRepoForGemini{
|
||||||
accounts: []Account{
|
accounts: []Account{
|
||||||
{ID: 1, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
{ID: 1, Platform: PlatformGemini, Priority: 2, Status: StatusActive, Schedulable: true},
|
||||||
{ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true},
|
{ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
||||||
},
|
},
|
||||||
accountsByID: map[int64]*Account{},
|
accountsByID: map[int64]*Account{},
|
||||||
}
|
}
|
||||||
@@ -373,9 +385,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS
|
|||||||
cache := &mockGatewayCacheForGemini{
|
cache := &mockGatewayCacheForGemini{
|
||||||
sessionBindings: map[string]int64{"session-123": 1},
|
sessionBindings: map[string]int64{"session-123": 1},
|
||||||
}
|
}
|
||||||
|
groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}}
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
svc := &GeminiMessagesCompatService{
|
||||||
accountRepo: repo,
|
accountRepo: repo,
|
||||||
|
groupRepo: groupRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,102 +397,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, acc)
|
require.NotNil(t, acc)
|
||||||
// 粘性会话未命中,按优先级选择
|
// 粘性会话未命中,按优先级选择
|
||||||
require.Equal(t, int64(2), acc.ID, "粘性会话未命中,应按优先级选择 Antigravity")
|
require.Equal(t, int64(2), acc.ID, "粘性会话未命中,应按优先级选择")
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("粘性会话Anthropic账户-降级选择", func(t *testing.T) {
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
|
|
||||||
{ID: 2, Platform: PlatformGemini, Priority: 1, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
|
||||||
accountsByID: map[int64]*Account{},
|
|
||||||
}
|
|
||||||
for i := range repo.accounts {
|
|
||||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &mockGatewayCacheForGemini{
|
|
||||||
sessionBindings: map[string]int64{"gemini:session-123": 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{
|
|
||||||
accountRepo: repo,
|
|
||||||
cache: cache,
|
|
||||||
}
|
|
||||||
|
|
||||||
acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "session-123", "gemini-2.5-flash", nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, acc)
|
|
||||||
// 粘性会话绑定的是 Anthropic 账户,不在 Gemini 平台列表中,应降级选择
|
|
||||||
require.Equal(t, int64(2), acc.ID, "粘性会话账户是 Anthropic,应降级选择 Gemini 平台账户")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeminiMessagesCompatService_HasAntigravityAccounts(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Run("有antigravity账户时返回true", func(t *testing.T) {
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformGemini, Status: StatusActive, Schedulable: true},
|
|
||||||
{ID: 2, Platform: PlatformAntigravity, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{accountRepo: repo}
|
|
||||||
|
|
||||||
has, err := svc.HasAntigravityAccounts(ctx, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, has)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("无antigravity账户时返回false", func(t *testing.T) {
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformGemini, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{accountRepo: repo}
|
|
||||||
|
|
||||||
has, err := svc.HasAntigravityAccounts(ctx, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, has)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("antigravity账户不可调度时返回false", func(t *testing.T) {
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAntigravity, Status: StatusActive, Schedulable: false},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{accountRepo: repo}
|
|
||||||
|
|
||||||
has, err := svc.HasAntigravityAccounts(ctx, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, has)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("带groupID查询", func(t *testing.T) {
|
|
||||||
repo := &mockAccountRepoForGemini{
|
|
||||||
accounts: []Account{
|
|
||||||
{ID: 1, Platform: PlatformAntigravity, Status: StatusActive, Schedulable: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &GeminiMessagesCompatService{accountRepo: repo}
|
|
||||||
|
|
||||||
groupID := int64(1)
|
|
||||||
has, err := svc.HasAntigravityAccounts(ctx, &groupID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, has)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGeminiPlatformRouting_DocumentRouteDecision 测试平台路由决策逻辑
|
// TestGeminiPlatformRouting_DocumentRouteDecision 测试平台路由决策逻辑
|
||||||
// 该测试文档化了 Handler 层应该如何根据 account.Platform 进行分流
|
|
||||||
func TestGeminiPlatformRouting_DocumentRouteDecision(t *testing.T) {
|
func TestGeminiPlatformRouting_DocumentRouteDecision(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1025,8 +1025,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mixed Scheduling (only for antigravity accounts) -->
|
||||||
|
<div v-if="form.platform === 'antigravity'" class="flex items-center gap-2">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="mixedScheduling"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.accounts.mixedScheduling') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="group relative">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group Selection -->
|
<!-- Group Selection -->
|
||||||
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
|
<GroupSelector
|
||||||
|
v-model="form.group_ids"
|
||||||
|
:groups="groups"
|
||||||
|
:platform="form.platform"
|
||||||
|
:mixed-scheduling="mixedScheduling"
|
||||||
|
/>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -1244,6 +1279,7 @@ const customErrorCodesEnabled = ref(false)
|
|||||||
const selectedErrorCodes = ref<number[]>([])
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
const customErrorCodeInput = ref<number | null>(null)
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
const interceptWarmupRequests = ref(false)
|
const interceptWarmupRequests = ref(false)
|
||||||
|
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||||
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
|
||||||
const geminiAIStudioOAuthEnabled = ref(false)
|
const geminiAIStudioOAuthEnabled = ref(false)
|
||||||
|
|
||||||
@@ -1730,7 +1766,7 @@ const createAccountAndFinish = async (
|
|||||||
platform: AccountPlatform,
|
platform: AccountPlatform,
|
||||||
type: AccountType,
|
type: AccountType,
|
||||||
credentials: Record<string, unknown>,
|
credentials: Record<string, unknown>,
|
||||||
extra?: Record<string, string>
|
extra?: Record<string, unknown>
|
||||||
) => {
|
) => {
|
||||||
await adminAPI.accounts.create({
|
await adminAPI.accounts.create({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -1834,7 +1870,8 @@ const handleAntigravityExchange = async (authCode: string) => {
|
|||||||
if (!tokenInfo) return
|
if (!tokenInfo) return
|
||||||
|
|
||||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||||
await createAccountAndFinish('antigravity', 'oauth', credentials)
|
const extra = mixedScheduling.value ? { mixed_scheduling: true } : undefined
|
||||||
|
await createAccountAndFinish('antigravity', 'oauth', credentials, extra)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||||
appStore.showError(antigravityOAuth.error.value)
|
appStore.showError(antigravityOAuth.error.value)
|
||||||
|
|||||||
@@ -466,8 +466,44 @@
|
|||||||
<Select v-model="form.status" :options="statusOptions" />
|
<Select v-model="form.status" :options="statusOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mixed Scheduling (only for antigravity accounts, read-only in edit mode) -->
|
||||||
|
<div v-if="account?.platform === 'antigravity'" class="flex items-center gap-2">
|
||||||
|
<label class="flex cursor-not-allowed items-center gap-2 opacity-60">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="mixedScheduling"
|
||||||
|
disabled
|
||||||
|
class="h-4 w-4 cursor-not-allowed rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('admin.accounts.mixedScheduling') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="group relative">
|
||||||
|
<span
|
||||||
|
class="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-gray-200 text-xs text-gray-500 hover:bg-gray-300 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
<!-- Tooltip(向下显示避免被弹窗裁剪) -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute left-0 top-full z-[100] mt-1.5 w-72 rounded bg-gray-900 px-3 py-2 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.mixedSchedulingTooltip') }}
|
||||||
|
<div
|
||||||
|
class="absolute bottom-full left-3 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Group Selection -->
|
<!-- Group Selection -->
|
||||||
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
|
<GroupSelector
|
||||||
|
v-model="form.group_ids"
|
||||||
|
:groups="groups"
|
||||||
|
:platform="account?.platform"
|
||||||
|
:mixed-scheduling="mixedScheduling"
|
||||||
|
/>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -553,6 +589,7 @@ const customErrorCodesEnabled = ref(false)
|
|||||||
const selectedErrorCodes = ref<number[]>([])
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
const customErrorCodeInput = ref<number | null>(null)
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
const interceptWarmupRequests = ref(false)
|
const interceptWarmupRequests = ref(false)
|
||||||
|
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||||
|
|
||||||
// Common models for whitelist - Anthropic
|
// Common models for whitelist - Anthropic
|
||||||
const anthropicModels = [
|
const anthropicModels = [
|
||||||
@@ -764,6 +801,10 @@ watch(
|
|||||||
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||||
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||||
|
|
||||||
|
// Load mixed scheduling setting (only for antigravity accounts)
|
||||||
|
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||||
|
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||||
|
|
||||||
// Initialize API Key fields for apikey type
|
// Initialize API Key fields for apikey type
|
||||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||||
const credentials = newAccount.credentials as Record<string, unknown>
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
@@ -969,6 +1010,18 @@ const handleSubmit = async () => {
|
|||||||
updatePayload.credentials = newCredentials
|
updatePayload.credentials = newCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For antigravity accounts, handle mixed_scheduling in extra
|
||||||
|
if (props.account.platform === 'antigravity') {
|
||||||
|
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||||
|
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||||
|
if (mixedScheduling.value) {
|
||||||
|
newExtra.mixed_scheduling = true
|
||||||
|
} else {
|
||||||
|
delete newExtra.mixed_scheduling
|
||||||
|
}
|
||||||
|
updatePayload.extra = newExtra
|
||||||
|
}
|
||||||
|
|
||||||
await adminAPI.accounts.update(props.account.id, updatePayload)
|
await adminAPI.accounts.update(props.account.id, updatePayload)
|
||||||
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
appStore.showSuccess(t('admin.accounts.accountUpdated'))
|
||||||
emit('updated')
|
emit('updated')
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface Props {
|
|||||||
modelValue: number[]
|
modelValue: number[]
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
platform?: GroupPlatform // Optional platform filter
|
platform?: GroupPlatform // Optional platform filter
|
||||||
|
mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -62,10 +63,13 @@ const filteredGroups = computed(() => {
|
|||||||
if (!props.platform) {
|
if (!props.platform) {
|
||||||
return props.groups
|
return props.groups
|
||||||
}
|
}
|
||||||
// antigravity 账户可选择 anthropic 和 gemini 平台的分组
|
// antigravity 账户启用混合调度后,可选择 anthropic/gemini 分组
|
||||||
if (props.platform === 'antigravity') {
|
if (props.platform === 'antigravity' && props.mixedScheduling) {
|
||||||
return props.groups.filter((g) => g.platform === 'anthropic' || g.platform === 'gemini')
|
return props.groups.filter(
|
||||||
|
(g) => g.platform === 'antigravity' || g.platform === 'anthropic' || g.platform === 'gemini'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
// 默认:只能选择同 platform 的分组
|
||||||
return props.groups.filter((g) => g.platform === props.platform)
|
return props.groups.filter((g) => g.platform === props.platform)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -940,6 +940,10 @@ export default {
|
|||||||
priority: 'Priority',
|
priority: 'Priority',
|
||||||
priorityHint: 'Higher priority accounts are used first',
|
priorityHint: 'Higher priority accounts are used first',
|
||||||
higherPriorityFirst: 'Higher value means higher priority',
|
higherPriorityFirst: 'Higher value means higher priority',
|
||||||
|
mixedScheduling: 'Mixed Scheduling',
|
||||||
|
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
|
||||||
|
mixedSchedulingTooltip:
|
||||||
|
'When enabled, this account can be scheduled by /v1/messages and /v1beta endpoints. Otherwise, it will only be scheduled by /antigravity. Note: Anthropic Claude and Antigravity Claude cannot be mixed in the same context. Please manage groups carefully when enabled.',
|
||||||
creating: 'Creating...',
|
creating: 'Creating...',
|
||||||
updating: 'Updating...',
|
updating: 'Updating...',
|
||||||
accountCreated: 'Account created successfully',
|
accountCreated: 'Account created successfully',
|
||||||
|
|||||||
@@ -1085,6 +1085,10 @@ export default {
|
|||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
priorityHint: '优先级越高的账号优先使用',
|
priorityHint: '优先级越高的账号优先使用',
|
||||||
higherPriorityFirst: '数值越高优先级越高',
|
higherPriorityFirst: '数值越高优先级越高',
|
||||||
|
mixedScheduling: '混合调度',
|
||||||
|
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
|
||||||
|
mixedSchedulingTooltip:
|
||||||
|
'开启后,该账户可被 /v1/messages 及 /v1beta 端点调度,否则只被 /antigravity 调度。注意:Anthropic Claude 和 Antigravity Claude 无法在同个上下文中混合使用,开启后请自行做好分组管理。',
|
||||||
creating: '创建中...',
|
creating: '创建中...',
|
||||||
updating: '更新中...',
|
updating: '更新中...',
|
||||||
accountCreated: '账号创建成功',
|
accountCreated: '账号创建成功',
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export interface CreateAccountRequest {
|
|||||||
platform: AccountPlatform
|
platform: AccountPlatform
|
||||||
type: AccountType
|
type: AccountType
|
||||||
credentials: Record<string, unknown>
|
credentials: Record<string, unknown>
|
||||||
extra?: Record<string, string>
|
extra?: Record<string, unknown>
|
||||||
proxy_id?: number | null
|
proxy_id?: number | null
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
priority?: number
|
priority?: number
|
||||||
@@ -403,7 +403,7 @@ export interface UpdateAccountRequest {
|
|||||||
name?: string
|
name?: string
|
||||||
type?: AccountType
|
type?: AccountType
|
||||||
credentials?: Record<string, unknown>
|
credentials?: Record<string, unknown>
|
||||||
extra?: Record<string, string>
|
extra?: Record<string, unknown>
|
||||||
proxy_id?: number | null
|
proxy_id?: number | null
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
priority?: number
|
priority?: number
|
||||||
|
|||||||
@@ -78,11 +78,21 @@
|
|||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
: value === 'openai'
|
: value === 'openai'
|
||||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
: value === 'antigravity'
|
||||||
|
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<PlatformIcon :platform="value" size="xs" />
|
<PlatformIcon :platform="value" size="xs" />
|
||||||
{{ value === 'anthropic' ? 'Anthropic' : value === 'openai' ? 'OpenAI' : 'Gemini' }}
|
{{
|
||||||
|
value === 'anthropic'
|
||||||
|
? 'Anthropic'
|
||||||
|
: value === 'openai'
|
||||||
|
? 'OpenAI'
|
||||||
|
: value === 'antigravity'
|
||||||
|
? 'Antigravity'
|
||||||
|
: 'Gemini'
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -604,14 +614,16 @@ const exclusiveOptions = computed(() => [
|
|||||||
const platformOptions = computed(() => [
|
const platformOptions = computed(() => [
|
||||||
{ value: 'anthropic', label: 'Anthropic' },
|
{ value: 'anthropic', label: 'Anthropic' },
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
{ value: 'gemini', label: 'Gemini' }
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
|
{ value: 'antigravity', label: 'Antigravity' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const platformFilterOptions = computed(() => [
|
const platformFilterOptions = computed(() => [
|
||||||
{ value: '', label: t('admin.groups.allPlatforms') },
|
{ value: '', label: t('admin.groups.allPlatforms') },
|
||||||
{ value: 'anthropic', label: 'Anthropic' },
|
{ value: 'anthropic', label: 'Anthropic' },
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
{ value: 'gemini', label: 'Gemini' }
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
|
{ value: 'antigravity', label: 'Antigravity' }
|
||||||
])
|
])
|
||||||
|
|
||||||
const editStatusOptions = computed(() => [
|
const editStatusOptions = computed(() => [
|
||||||
|
|||||||
Reference in New Issue
Block a user