diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index fe4e9a34..8b22c3b4 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -121,10 +121,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { identityService := service.NewIdentityService(identityCache) timingWheelService := service.ProvideTimingWheelService() 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) 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) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService) diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 968d5db2..30225b76 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -26,7 +26,7 @@ func NewGroupHandler(adminService service.AdminService) *GroupHandler { type CreateGroupRequest struct { Name string `json:"name" binding:"required"` 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"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -39,7 +39,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` 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"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` diff --git a/backend/internal/integration/e2e_gateway_test.go b/backend/internal/integration/e2e_gateway_test.go index b1bb965d..ae0b138a 100644 --- a/backend/internal/integration/e2e_gateway_test.go +++ b/backend/internal/integration/e2e_gateway_test.go @@ -16,10 +16,14 @@ import ( ) 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" geminiAPIKey = "sk-5950197a2085b38bbe5a1b229cc02b8ece914963fc44cacc06d497ae8b87410f" - testInterval = 3 * time.Second // 测试间隔,防止限流 + testInterval = 1 * time.Second // 测试间隔,防止限流 ) func getEnv(key, defaultVal string) string { @@ -32,9 +36,9 @@ func getEnv(key, defaultVal string) string { // Claude 模型列表 var claudeModels = []string{ // Opus 系列 - "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-thinking", // 直接支持 + "claude-opus-4", // 映射到 claude-opus-4-5-thinking + "claude-opus-4-5-20251101", // 映射到 claude-opus-4-5-thinking // Sonnet 系列 "claude-sonnet-4-5", // 直接支持 "claude-sonnet-4-5-thinking", // 直接支持 @@ -56,13 +60,17 @@ var geminiModels = []string{ } 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()) } // TestClaudeModelsList 测试 GET /v1/models func TestClaudeModelsList(t *testing.T) { - url := baseURL + "/v1/models" + url := baseURL + endpointPrefix + "/v1/models" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+claudeAPIKey) @@ -97,7 +105,7 @@ func TestClaudeModelsList(t *testing.T) { // TestGeminiModelsList 测试 GET /v1beta/models func TestGeminiModelsList(t *testing.T) { - url := baseURL + "/v1beta/models" + url := baseURL + endpointPrefix + "/v1beta/models" req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+geminiAPIKey) @@ -143,7 +151,7 @@ func TestClaudeMessages(t *testing.T) { } func testClaudeMessage(t *testing.T, model string, stream bool) { - url := baseURL + "/v1/messages" + url := baseURL + endpointPrefix + "/v1/messages" payload := map[string]any{ "model": model, @@ -294,8 +302,8 @@ func testGeminiGenerate(t *testing.T, model string, stream bool) { func TestClaudeMessagesWithComplexTools(t *testing.T) { // 测试模型列表(只测试几个代表性模型) models := []string{ - "claude-opus-4-5-20251101", // Claude 模型 - "claude-haiku-4-5-20251001", // 映射到 Gemini + "claude-opus-4-5-20251101", // Claude 模型 + "claude-haiku-4-5-20251001", // 映射到 Gemini } for i, model := range models { @@ -309,7 +317,7 @@ func TestClaudeMessagesWithComplexTools(t *testing.T) { } func testClaudeMessageWithTools(t *testing.T, model string) { - url := baseURL + "/v1/messages" + url := baseURL + endpointPrefix + "/v1/messages" // 构造包含复杂 schema 的工具定义(模拟 Claude Code 的工具) // 这些字段需要被 cleanJSONSchema 清理 @@ -524,7 +532,7 @@ func TestClaudeMessagesWithThinkingAndTools(t *testing.T) { } func testClaudeThinkingWithToolHistory(t *testing.T, model string) { - url := baseURL + "/v1/messages" + url := baseURL + endpointPrefix + "/v1/messages" // 模拟历史对话:用户请求 → assistant 调用工具 → 工具返回 → 继续对话 // 注意:tool_use 块故意不包含 signature,测试系统是否能正确添加 dummy signature @@ -650,7 +658,7 @@ func TestClaudeMessagesWithNoSignature(t *testing.T) { } func testClaudeWithNoSignature(t *testing.T, model string) { - url := baseURL + "/v1/messages" + url := baseURL + endpointPrefix + "/v1/messages" // 模拟历史对话包含 thinking block 但没有 signature payload := map[string]any{ @@ -730,104 +738,3 @@ func testClaudeWithNoSignature(t *testing.T, model string) { } 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"]) -} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 5bcd98f5..bfe3822c 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -346,3 +346,20 @@ func (a *Account) IsOpenAITokenExpired() bool { } 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 +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index df424f25..4dfef5f4 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -12,25 +12,85 @@ import ( "github.com/stretchr/testify/require" ) -// mockAccountRepoForMultiplatform 多平台测试用的 mock -type mockAccountRepoForMultiplatform struct { +// mockAccountRepoForPlatform 单平台测试用的 mock +type mockAccountRepoForPlatform struct { accounts []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 { return acc, nil } return nil, errors.New("account not found") } -func (m *mockAccountRepoForMultiplatform) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) { - if m.listPlatformsFunc != nil { - return m.listPlatformsFunc(ctx, platforms) +func (m *mockAccountRepoForPlatform) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) { + if m.listPlatformFunc != nil { + 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 platformSet := make(map[string]bool) for _, p := range platforms { @@ -43,99 +103,44 @@ func (m *mockAccountRepoForMultiplatform) ListSchedulableByPlatforms(ctx context } return result, nil } - -func (m *mockAccountRepoForMultiplatform) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) { +func (m *mockAccountRepoForPlatform) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) { return m.ListSchedulableByPlatforms(ctx, platforms) } - -// Stub methods to implement AccountRepository interface -func (m *mockAccountRepoForMultiplatform) Create(ctx context.Context, account *Account) error { +func (m *mockAccountRepoForPlatform) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error { return nil } -func (m *mockAccountRepoForMultiplatform) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error) { - return nil, nil -} -func (m *mockAccountRepoForMultiplatform) Update(ctx context.Context, account *Account) error { +func (m *mockAccountRepoForPlatform) SetOverloaded(ctx context.Context, id int64, until time.Time) error { return nil } -func (m *mockAccountRepoForMultiplatform) Delete(ctx context.Context, id int64) error { return nil } -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 { +func (m *mockAccountRepoForPlatform) ClearRateLimit(ctx context.Context, id int64) error { 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 } -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 } -func (m *mockAccountRepoForMultiplatform) SetSchedulable(ctx context.Context, id int64, schedulable bool) 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) { +func (m *mockAccountRepoForPlatform) BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) { return 0, nil } // Verify interface implementation -var _ AccountRepository = (*mockAccountRepoForMultiplatform)(nil) +var _ AccountRepository = (*mockAccountRepoForPlatform)(nil) -// mockGatewayCacheForMultiplatform 多平台测试用的 cache mock -type mockGatewayCacheForMultiplatform struct { +// mockGatewayCacheForPlatform 单平台测试用的 cache mock +type mockGatewayCacheForPlatform struct { 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 { return id, nil } 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 { m.sessionBindings = make(map[string]int64) } @@ -143,7 +148,7 @@ func (m *mockGatewayCacheForMultiplatform) SetSessionAccountID(ctx context.Conte 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 } @@ -151,13 +156,15 @@ func ptr[T any](v T) *T { return &v } -func TestGatewayService_SelectAccountForModelWithExclusions_OnlyAnthropic(t *testing.T) { +// TestGatewayService_SelectAccountForModelWithPlatform_Anthropic 测试 anthropic 单平台选择 +func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T) { ctx := context.Background() - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{ {ID: 1, Platform: PlatformAnthropic, Priority: 1, 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{}, } @@ -165,80 +172,27 @@ func TestGatewayService_SelectAccountForModelWithExclusions_OnlyAnthropic(t *tes repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, 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.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() - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{ - {ID: 1, Platform: PlatformAntigravity, 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: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, // 应被隔离 {ID: 2, Platform: PlatformAntigravity, Priority: 1, Status: StatusActive, Schedulable: true}, }, accountsByID: map[int64]*Account{}, @@ -247,32 +201,29 @@ func TestGatewayService_SelectAccountForModelWithExclusions_MixedPlatforms_DiffP repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, 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.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() + now := time.Now() - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{ - // Anthropic 账户配置了模型映射,只支持 other-model - // 注意:model_mapping 需要是 map[string]any 格式 - { - 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}, + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-1 * time.Hour))}, + {ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, LastUsedAt: ptr(now.Add(-2 * time.Hour))}, }, accountsByID: map[int64]*Account{}, } @@ -280,47 +231,49 @@ func TestGatewayService_SelectAccountForModelWithExclusions_ModelNotSupported(t repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, 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.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() - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{}, accountsByID: map[int64]*Account{}, } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, 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.Nil(t, acc) 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() - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{ {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{}, } @@ -328,7 +281,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_AllExcluded(t *testi repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, @@ -336,12 +289,13 @@ func TestGatewayService_SelectAccountForModelWithExclusions_AllExcluded(t *testi } 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.Nil(t, acc) } -func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *testing.T) { +// TestGatewayService_SelectAccountForModelWithPlatform_Schedulability 测试账户可调度性检查 +func TestGatewayService_SelectAccountForModelWithPlatform_Schedulability(t *testing.T) { ctx := context.Background() now := time.Now() @@ -354,7 +308,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te name: "过载账户被跳过", accounts: []Account{ {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, }, @@ -362,7 +316,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te name: "限流账户被跳过", accounts: []Account{ {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, }, @@ -370,7 +324,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te name: "非active账户被跳过", accounts: []Account{ {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, }, @@ -378,7 +332,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te name: "schedulable=false被跳过", accounts: []Account{ {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, }, @@ -386,7 +340,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te name: "过期的过载账户可调度", accounts: []Account{ {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, }, @@ -394,7 +348,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: tt.accounts, accountsByID: map[int64]*Account{}, } @@ -402,14 +356,14 @@ func TestGatewayService_SelectAccountForModelWithExclusions_Schedulability(t *te repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{} + cache := &mockGatewayCacheForPlatform{} svc := &GatewayService{ accountRepo: repo, 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.NotNil(t, acc) 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() - t.Run("粘性会话命中", func(t *testing.T) { - repo := &mockAccountRepoForMultiplatform{ + t.Run("粘性会话命中-同平台", 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}, + {ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, }, accountsByID: map[int64]*Account{}, } @@ -432,7 +387,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{ + cache := &mockGatewayCacheForPlatform{ sessionBindings: map[string]int64{"session-123": 1}, } @@ -441,17 +396,17 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes 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.NotNil(t, acc) require.Equal(t, int64(1), acc.ID, "应返回粘性会话绑定的账户") }) - t.Run("粘性会话账户被排除-降级选择", func(t *testing.T) { - repo := &mockAccountRepoForMultiplatform{ + t.Run("粘性会话不匹配平台-降级选择", 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}, + {ID: 1, Platform: PlatformAntigravity, Priority: 2, Status: StatusActive, Schedulable: true}, // 粘性会话绑定但平台不匹配 + {ID: 2, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true}, }, accountsByID: map[int64]*Account{}, } @@ -459,7 +414,36 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes 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}, } @@ -469,17 +453,17 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes } 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.NotNil(t, acc) require.Equal(t, int64(2), acc.ID, "粘性会话账户被排除,应选择其他账户") }) t.Run("粘性会话账户不可调度-降级选择", func(t *testing.T) { - repo := &mockAccountRepoForMultiplatform{ + repo := &mockAccountRepoForPlatform{ accounts: []Account{ {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{}, } @@ -487,7 +471,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] } - cache := &mockGatewayCacheForMultiplatform{ + cache := &mockGatewayCacheForPlatform{ sessionBindings: map[string]int64{"session-123": 1}, } @@ -496,7 +480,7 @@ func TestGatewayService_SelectAccountForModelWithExclusions_StickySession(t *tes 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.NotNil(t, acc) 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) + }) + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index dda185a3..6b286599 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -93,6 +93,7 @@ func (e *UpstreamFailoverError) Error() string { // GatewayService handles API gateway operations type GatewayService struct { accountRepo AccountRepository + groupRepo GroupRepository usageLogRepo UsageLogRepository userRepo UserRepository userSubRepo UserSubscriptionRepository @@ -109,6 +110,7 @@ type GatewayService struct { // NewGatewayService creates a new GatewayService func NewGatewayService( accountRepo AccountRepository, + groupRepo GroupRepository, usageLogRepo UsageLogRepository, userRepo UserRepository, userSubRepo UserSubscriptionRepository, @@ -123,6 +125,7 @@ func NewGatewayService( ) *GatewayService { return &GatewayService{ accountRepo: accountRepo, + groupRepo: groupRepo, usageLogRepo: usageLogRepo, userRepo: userRepo, 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. func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) { - // 使用多平台账户选择,包含 anthropic 和 antigravity 平台 - platforms := []string{PlatformAnthropic, PlatformAntigravity} - return s.selectAccountForModelWithPlatforms(ctx, groupID, sessionHash, requestedModel, excludedIDs, platforms) + // 根据分组 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 { + // 无分组时只使用原生 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 选择多平台账户 -func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platforms []string) (*Account, error) { +// selectAccountForModelWithPlatform 选择单平台账户(完全隔离) +func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, platform string) (*Account, error) { // 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) - // 使用IsSchedulable代替IsActive,确保限流/过载账号不会被选中 - // 同时检查模型支持(根据平台类型分别处理) - if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { - // 续期粘性会话 + // 检查账号平台是否匹配(确保粘性会话不会跨平台) + if err == nil && account.Platform == platform && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { if err := s.cache.RefreshSessionTTL(ctx, sessionHash, stickySessionTTL); err != nil { 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 err error if groupID != nil { - accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms) + accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, platform) } else { - accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms) + accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } if err != nil { 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 { continue } - // 检查模型支持(根据平台类型分别处理) if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } @@ -344,11 +361,98 @@ func (s *GatewayService) selectAccountForModelWithPlatforms(ctx context.Context, selected = acc continue } - // 优先选择priority值更小的(priority值越小优先级越高) if acc.Priority < selected.Priority { selected = acc } 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 { case acc.LastUsedAt == nil && selected.LastUsedAt != nil: selected = acc diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 1e7f23af..2f92abfc 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -33,16 +33,18 @@ const ( ) type GeminiMessagesCompatService struct { - accountRepo AccountRepository - cache GatewayCache - tokenProvider *GeminiTokenProvider - rateLimitService *RateLimitService - httpUpstream HTTPUpstream + accountRepo AccountRepository + groupRepo GroupRepository + cache GatewayCache + tokenProvider *GeminiTokenProvider + rateLimitService *RateLimitService + httpUpstream HTTPUpstream antigravityGatewayService *AntigravityGatewayService } func NewGeminiMessagesCompatService( accountRepo AccountRepository, + groupRepo GroupRepository, cache GatewayCache, tokenProvider *GeminiTokenProvider, rateLimitService *RateLimitService, @@ -50,11 +52,12 @@ func NewGeminiMessagesCompatService( antigravityGatewayService *AntigravityGatewayService, ) *GeminiMessagesCompatService { return &GeminiMessagesCompatService{ - accountRepo: accountRepo, - cache: cache, - tokenProvider: tokenProvider, - rateLimitService: rateLimitService, - httpUpstream: httpUpstream, + accountRepo: accountRepo, + groupRepo: groupRepo, + cache: cache, + tokenProvider: tokenProvider, + rateLimitService: rateLimitService, + httpUpstream: httpUpstream, 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) { + // 根据分组 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 - platforms := []string{PlatformGemini, PlatformAntigravity} if sessionHash != "" { accountID, err := s.cache.GetSessionAccountID(ctx, cacheKey) if err == nil && accountID > 0 { if _, excluded := excludedIDs[accountID]; !excluded { account, err := s.accountRepo.GetByID(ctx, accountID) - // 支持 gemini 和 antigravity 平台的粘性会话 - if err == nil && account.IsSchedulable() && (account.Platform == PlatformGemini || account.Platform == PlatformAntigravity) && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { - _ = s.cache.RefreshSessionTTL(ctx, cacheKey, geminiStickySessionTTL) - return account, nil + // 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度 + if err == nil && account.IsSchedulable() && (requestedModel == "" || s.isModelSupportedByAccount(account, requestedModel)) { + valid := false + 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 err error if groupID != nil { - accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, platforms) + accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, queryPlatforms) } else { - accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms) + accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, queryPlatforms) } if err != nil { 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 { continue } - // 根据平台类型分别检查模型支持 + // 混合调度模式下:原生平台直接通过,antigravity 需要启用 mixed_scheduling + // 非混合调度模式(antigravity 分组):不需要过滤 + if useMixedScheduling && acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() { + continue + } if requestedModel != "" && !s.isModelSupportedByAccount(acc, requestedModel) { continue } @@ -135,9 +171,9 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co if selected == nil { 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 != "" { diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 9fd8ae49..1b5f7bc3 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -25,22 +25,19 @@ func (m *mockAccountRepoForGemini) GetByID(ctx context.Context, id int64) (*Acco return nil, errors.New("account not found") } -func (m *mockAccountRepoForGemini) ListSchedulableByPlatforms(ctx context.Context, platforms []string) ([]Account, error) { - platformSet := make(map[string]bool) - for _, p := range platforms { - platformSet[p] = true - } +func (m *mockAccountRepoForGemini) ListSchedulableByPlatform(ctx context.Context, platform string) ([]Account, error) { var result []Account for _, acc := range m.accounts { - if platformSet[acc.Platform] && acc.IsSchedulable() { + if acc.Platform == platform && acc.IsSchedulable() { result = append(result, acc) } } return result, nil } -func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) { - return m.ListSchedulableByPlatforms(ctx, platforms) +func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) { + // 测试时不区分 groupID,直接按 platform 过滤 + return m.ListSchedulableByPlatform(ctx, platform) } // 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) { 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 + platformSet := make(map[string]bool) + for _, p := range platforms { + platformSet[p] = true + } for _, acc := range m.accounts { - if acc.Platform == platform && acc.IsSchedulable() { + if platformSet[acc.Platform] && acc.IsSchedulable() { result = append(result, acc) } } return result, nil } -func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]Account, error) { - // 测试时不区分 groupID,直接按 platform 过滤 - return m.ListSchedulableByPlatform(ctx, platform) +func (m *mockAccountRepoForGemini) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]Account, error) { + return m.ListSchedulableByPlatforms(ctx, platforms) } func (m *mockAccountRepoForGemini) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error { return nil @@ -115,6 +115,47 @@ func (m *mockAccountRepoForGemini) BulkUpdate(ctx context.Context, ids []int64, // Verify interface implementation 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 type mockGatewayCacheForGemini struct { sessionBindings map[string]int64 @@ -139,13 +180,15 @@ func (m *mockGatewayCacheForGemini) RefreshSessionTTL(ctx context.Context, sessi return nil } -func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyGemini(t *testing.T) { +// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 +func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { ctx := context.Background() repo := &mockAccountRepoForGemini{ accounts: []Account{ {ID: 1, Platform: PlatformGemini, Priority: 1, 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{}, } @@ -154,24 +197,30 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyGem } cache := &mockGatewayCacheForGemini{} + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, cache: cache, } + // 无分组时使用 gemini 平台 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, 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() repo := &mockAccountRepoForGemini{ 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{}, } @@ -180,76 +229,27 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OnlyAnt } 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(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}, + groupRepo := &mockGroupRepoForGemini{ + groups: map[int64]*Group{ + 1: {ID: 1, Platform: PlatformAntigravity}, }, - accountsByID: map[int64]*Account{}, } - for i := range repo.accounts { - repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] - } - - cache := &mockGatewayCacheForGemini{} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, 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.NotNil(t, acc) - // Anthropic 不在 [gemini, antigravity] 平台列表中,应被过滤 - require.Equal(t, int64(2), acc.ID, "Anthropic 平台应被排除,选择 Gemini") -} - -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)") + require.Equal(t, int64(2), acc.ID) + require.Equal(t, PlatformAntigravity, acc.Platform, "antigravity 分组应只返回 antigravity 账户") } +// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred 测试 OAuth 优先 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred(t *testing.T) { ctx := context.Background() @@ -265,9 +265,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPr } cache := &mockGatewayCacheForGemini{} + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, cache: cache, } @@ -278,33 +280,7 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPr require.Equal(t, AccountTypeOAuth, acc.Type) } -func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_OAuthPreferred_MixedPlatforms(t *testing.T) { - 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 账户") -} - +// TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvailableAccounts 测试无可用账户 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvailableAccounts(t *testing.T) { ctx := context.Background() @@ -314,26 +290,29 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_NoAvail } cache := &mockGatewayCacheForGemini{} + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, cache: cache, } acc, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gemini-2.5-flash", nil) require.Error(t, err) 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) { ctx := context.Background() - t.Run("粘性会话命中-使用gemini前缀缓存键", func(t *testing.T) { + t.Run("粘性会话命中-同平台", func(t *testing.T) { repo := &mockAccountRepoForGemini{ accounts: []Account{ {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{}, } @@ -345,9 +324,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS cache := &mockGatewayCacheForGemini{ sessionBindings: map[string]int64{"gemini:session-123": 1}, } + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, cache: cache, } @@ -357,11 +338,42 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS 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) { repo := &mockAccountRepoForGemini{ accounts: []Account{ {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{}, } @@ -373,9 +385,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS cache := &mockGatewayCacheForGemini{ sessionBindings: map[string]int64{"session-123": 1}, } + groupRepo := &mockGroupRepoForGemini{groups: map[int64]*Group{}} svc := &GeminiMessagesCompatService{ accountRepo: repo, + groupRepo: groupRepo, cache: cache, } @@ -383,102 +397,11 @@ func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_StickyS require.NoError(t, err) require.NotNil(t, acc) // 粘性会话未命中,按优先级选择 - require.Equal(t, int64(2), acc.ID, "粘性会话未命中,应按优先级选择 Antigravity") - }) - - 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) + require.Equal(t, int64(2), acc.ID, "粘性会话未命中,应按优先级选择") }) } // TestGeminiPlatformRouting_DocumentRouteDecision 测试平台路由决策逻辑 -// 该测试文档化了 Handler 层应该如何根据 account.Platform 进行分流 func TestGeminiPlatformRouting_DocumentRouteDecision(t *testing.T) { tests := []struct { name string diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 35a99c7a..1b12df01 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1025,8 +1025,43 @@ + +
+ +
+ + ? + + +
+ {{ t('admin.accounts.mixedSchedulingTooltip') }} +
+
+
+
+ - + @@ -1244,6 +1279,7 @@ const customErrorCodesEnabled = ref(false) const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) +const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist') const geminiAIStudioOAuthEnabled = ref(false) @@ -1730,7 +1766,7 @@ const createAccountAndFinish = async ( platform: AccountPlatform, type: AccountType, credentials: Record, - extra?: Record + extra?: Record ) => { await adminAPI.accounts.create({ name: form.name, @@ -1834,7 +1870,8 @@ const handleAntigravityExchange = async (authCode: string) => { if (!tokenInfo) return 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) { antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed') appStore.showError(antigravityOAuth.error.value) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dc981650..11b01b17 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -466,8 +466,44 @@ + + {{ t('admin.accounts.mixedScheduling') }} + + +
+ + ? + + +
+ {{ t('admin.accounts.mixedSchedulingTooltip') }} +
+
+
+ + - + @@ -553,6 +589,7 @@ const customErrorCodesEnabled = ref(false) const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) +const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling // Common models for whitelist - Anthropic const anthropicModels = [ @@ -764,6 +801,10 @@ watch( const credentials = newAccount.credentials as Record | undefined interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true + // Load mixed scheduling setting (only for antigravity accounts) + const extra = newAccount.extra as Record | undefined + mixedScheduling.value = extra?.mixed_scheduling === true + // Initialize API Key fields for apikey type if (newAccount.type === 'apikey' && newAccount.credentials) { const credentials = newAccount.credentials as Record @@ -969,6 +1010,18 @@ const handleSubmit = async () => { updatePayload.credentials = newCredentials } + // For antigravity accounts, handle mixed_scheduling in extra + if (props.account.platform === 'antigravity') { + const currentExtra = (props.account.extra as Record) || {} + const newExtra: Record = { ...currentExtra } + if (mixedScheduling.value) { + newExtra.mixed_scheduling = true + } else { + delete newExtra.mixed_scheduling + } + updatePayload.extra = newExtra + } + await adminAPI.accounts.update(props.account.id, updatePayload) appStore.showSuccess(t('admin.accounts.accountUpdated')) emit('updated') diff --git a/frontend/src/components/common/GroupSelector.vue b/frontend/src/components/common/GroupSelector.vue index 1db827e6..5b78808b 100644 --- a/frontend/src/components/common/GroupSelector.vue +++ b/frontend/src/components/common/GroupSelector.vue @@ -50,6 +50,7 @@ interface Props { modelValue: number[] groups: Group[] platform?: GroupPlatform // Optional platform filter + mixedScheduling?: boolean // For antigravity accounts: allow anthropic/gemini groups } const props = defineProps() @@ -62,10 +63,13 @@ const filteredGroups = computed(() => { if (!props.platform) { return props.groups } - // antigravity 账户可选择 anthropic 和 gemini 平台的分组 - if (props.platform === 'antigravity') { - return props.groups.filter((g) => g.platform === 'anthropic' || g.platform === 'gemini') + // antigravity 账户启用混合调度后,可选择 anthropic/gemini 分组 + if (props.platform === 'antigravity' && props.mixedScheduling) { + return props.groups.filter( + (g) => g.platform === 'antigravity' || g.platform === 'anthropic' || g.platform === 'gemini' + ) } + // 默认:只能选择同 platform 的分组 return props.groups.filter((g) => g.platform === props.platform) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d43338ee..fbdad326 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -940,6 +940,10 @@ export default { priority: 'Priority', priorityHint: 'Higher priority accounts are used first', 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...', updating: 'Updating...', accountCreated: 'Account created successfully', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 68e53db2..110a5f94 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1085,6 +1085,10 @@ export default { priority: '优先级', priorityHint: '优先级越高的账号优先使用', higherPriorityFirst: '数值越高优先级越高', + mixedScheduling: '混合调度', + mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度', + mixedSchedulingTooltip: + '开启后,该账户可被 /v1/messages 及 /v1beta 端点调度,否则只被 /antigravity 调度。注意:Anthropic Claude 和 Antigravity Claude 无法在同个上下文中混合使用,开启后请自行做好分组管理。', creating: '创建中...', updating: '更新中...', accountCreated: '账号创建成功', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e0d95267..e58d5fdb 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -392,7 +392,7 @@ export interface CreateAccountRequest { platform: AccountPlatform type: AccountType credentials: Record - extra?: Record + extra?: Record proxy_id?: number | null concurrency?: number priority?: number @@ -403,7 +403,7 @@ export interface UpdateAccountRequest { name?: string type?: AccountType credentials?: Record - extra?: Record + extra?: Record proxy_id?: number | null concurrency?: number priority?: number diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 7d8bba6a..7ef23d58 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -78,11 +78,21 @@ ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : value === 'openai' ? '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' ]" > - {{ value === 'anthropic' ? 'Anthropic' : value === 'openai' ? 'OpenAI' : 'Gemini' }} + {{ + value === 'anthropic' + ? 'Anthropic' + : value === 'openai' + ? 'OpenAI' + : value === 'antigravity' + ? 'Antigravity' + : 'Gemini' + }} @@ -604,14 +614,16 @@ const exclusiveOptions = computed(() => [ const platformOptions = computed(() => [ { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, - { value: 'gemini', label: 'Gemini' } + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' } ]) const platformFilterOptions = computed(() => [ { value: '', label: t('admin.groups.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, - { value: 'gemini', label: 'Gemini' } + { value: 'gemini', label: 'Gemini' }, + { value: 'antigravity', label: 'Antigravity' } ]) const editStatusOptions = computed(() => [