From 90ff3c80f0900cfd6d58e90f019d22d50bea41ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Wed, 15 Apr 2026 17:18:09 +0800 Subject: [PATCH] Route Anthropic model rules through Codex OAuth accounts Anthropic model routing is explicitly configured, so Codex/OpenAI OAuth accounts can be treated as routed targets without expanding the default Anthropic scheduling pool. The gateway dispatch path reuses the existing OpenAI Responses compatibility forwarder when an Anthropic group selects an OpenAI OAuth account. Constraint: Codex account support is limited to platform=openai and type=oauth Constraint: Public API shapes and group/account schemas must remain unchanged Rejected: Add OpenAI OAuth accounts to the normal Anthropic candidate pool | would change default scheduling behavior Rejected: Add new routing fields | model_routing already stores explicit account IDs Confidence: high Scope-risk: moderate Directive: Do not allow OpenAI accounts into Anthropic default scheduling; only explicit model_routing hits may use this path Tested: pnpm test:run src/views/admin/__tests__/groupsModelRoutingAccounts.spec.ts Tested: pnpm typecheck Tested: go test ./internal/handler -run TestMessagesForwardRouteKind -count=1 Tested: go test -tags unit ./internal/service -run '^TestGatewayService_SelectAccountWithLoadAwareness$' -count=1 -v Not-tested: Full backend test suite --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/handler/gateway_handler.go | 76 +++++++++++- .../gateway_handler_codex_routing_test.go | 26 ++++ .../service/gateway_multiplatform_test.go | 112 ++++++++++++++++++ backend/internal/service/gateway_service.go | 111 +++++++++++++---- frontend/src/views/admin/GroupsView.vue | 41 ++++--- .../groupsModelRoutingAccounts.spec.ts | 96 +++++++++++++++ .../views/admin/groupsModelRoutingAccounts.ts | 80 +++++++++++++ 8 files changed, 499 insertions(+), 45 deletions(-) create mode 100644 backend/internal/handler/gateway_handler_codex_routing_test.go create mode 100644 frontend/src/views/admin/__tests__/groupsModelRoutingAccounts.spec.ts create mode 100644 frontend/src/views/admin/groupsModelRoutingAccounts.ts diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1d39fa1e31a..4a187d0538a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -225,7 +225,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService) + gatewayHandler := handler.NewGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) totpHandler := handler.NewTotpHandler(totpService) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index f5eff8c9a51..3542fc2c0cc 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -37,6 +37,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64 // GatewayHandler handles API gateway requests type GatewayHandler struct { gatewayService *service.GatewayService + openAIGatewayService *service.OpenAIGatewayService geminiCompatService *service.GeminiMessagesCompatService antigravityGatewayService *service.AntigravityGatewayService userService *service.UserService @@ -56,6 +57,7 @@ type GatewayHandler struct { // NewGatewayHandler creates a new GatewayHandler func NewGatewayHandler( gatewayService *service.GatewayService, + openAIGatewayService *service.OpenAIGatewayService, geminiCompatService *service.GeminiMessagesCompatService, antigravityGatewayService *service.AntigravityGatewayService, userService *service.UserService, @@ -90,6 +92,7 @@ func NewGatewayHandler( return &GatewayHandler{ gatewayService: gatewayService, + openAIGatewayService: openAIGatewayService, geminiCompatService: geminiCompatService, antigravityGatewayService: antigravityGatewayService, userService: userService, @@ -107,6 +110,52 @@ func NewGatewayHandler( } } +type messagesForwardRoute int + +const ( + messagesForwardRouteAnthropicNative messagesForwardRoute = iota + messagesForwardRouteOpenAICompat + messagesForwardRouteAntigravity +) + +func messagesForwardRouteKind(group *service.Group, account *service.Account) messagesForwardRoute { + if group != nil && group.Platform == service.PlatformAnthropic && account != nil && account.IsOpenAIOAuth() { + return messagesForwardRouteOpenAICompat + } + if account != nil && account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey { + return messagesForwardRouteAntigravity + } + return messagesForwardRouteAnthropicNative +} + +func openAICompatForwardResult(result *service.OpenAIForwardResult) *service.ForwardResult { + if result == nil { + return nil + } + inputTokens := result.Usage.InputTokens - result.Usage.CacheReadInputTokens + if inputTokens < 0 { + inputTokens = 0 + } + return &service.ForwardResult{ + RequestID: result.RequestID, + Usage: service.ClaudeUsage{ + InputTokens: inputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationInputTokens: result.Usage.CacheCreationInputTokens, + CacheReadInputTokens: result.Usage.CacheReadInputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + }, + Model: result.Model, + BillingModel: result.BillingModel, + UpstreamModel: result.UpstreamModel, + ServiceTier: result.ServiceTier, + Stream: result.Stream, + Duration: result.Duration, + FirstTokenMs: result.FirstTokenMs, + ReasoningEffort: result.ReasoningEffort, + } +} + // Messages handles Claude API compatible messages endpoint // POST /v1/messages func (h *GatewayHandler) Messages(c *gin.Context) { @@ -684,9 +733,32 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } // 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover writerSizeBeforeForward := c.Writer.Size() - if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey { + switch messagesForwardRouteKind(currentAPIKey.Group, account) { + case messagesForwardRouteAntigravity: result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, body, hasBoundSession) - } else { + case messagesForwardRouteOpenAICompat: + if h.openAIGatewayService == nil { + err = errors.New("openai gateway service unavailable") + break + } + defaultMappedModel := "" + if currentAPIKey.Group != nil { + defaultMappedModel = strings.TrimSpace(currentAPIKey.Group.ResolveMessagesDispatchModel(reqModel)) + if defaultMappedModel == "" { + defaultMappedModel = strings.TrimSpace(currentAPIKey.Group.ResolveMessagesDispatchModel(parsedReq.Model)) + } + } + openAIResult, forwardErr := h.openAIGatewayService.ForwardAsAnthropic( + requestCtx, + c, + account, + body, + sessionKey, + defaultMappedModel, + ) + err = forwardErr + result = openAICompatForwardResult(openAIResult) + default: result, err = h.gatewayService.Forward(requestCtx, c, account, parsedReq) } diff --git a/backend/internal/handler/gateway_handler_codex_routing_test.go b/backend/internal/handler/gateway_handler_codex_routing_test.go new file mode 100644 index 00000000000..ccb7e628601 --- /dev/null +++ b/backend/internal/handler/gateway_handler_codex_routing_test.go @@ -0,0 +1,26 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestMessagesForwardRouteKind(t *testing.T) { + t.Run("anthropic group routed to openai oauth uses openai compat forwarder", func(t *testing.T) { + kind := messagesForwardRouteKind( + &service.Group{Platform: service.PlatformAnthropic}, + &service.Account{Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, + ) + require.Equal(t, messagesForwardRouteOpenAICompat, kind) + }) + + t.Run("anthropic native accounts keep anthropic forwarder", func(t *testing.T) { + kind := messagesForwardRouteKind( + &service.Group{Platform: service.PlatformAnthropic}, + &service.Account{Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, + ) + require.Equal(t, messagesForwardRouteAnthropicNative, kind) + }) +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 728328373c6..1e6279772bb 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -2775,6 +2775,118 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) { require.Equal(t, int64(3), cache.sessionBindings["fallback"]) }) + t.Run("模型路由-可显式路由到OpenAI OAuth账号", func(t *testing.T) { + groupID := int64(24) + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5}, + {ID: 99, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Priority: 0, Status: StatusActive, Schedulable: true, Concurrency: 5, AccountGroups: []AccountGroup{{GroupID: groupID}}}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForPlatform{} + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{ + groupID: { + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{ + "claude-sonnet-4-5-20250929": {99}, + }, + }, + }, + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + concurrencyCache := &mockConcurrencyCache{ + loadMap: map[int64]*AccountLoadInfo{ + 1: {AccountID: 1, LoadRate: 10}, + 99: {AccountID: 99, LoadRate: 0}, + }, + } + + svc := &GatewayService{ + accountRepo: repo, + groupRepo: groupRepo, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "codex-route", "claude-sonnet-4-5-20250929", nil, "", int64(0)) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(99), result.Account.ID) + }) + + t.Run("模型路由-未绑定分组的OpenAI OAuth账号会被跳过", func(t *testing.T) { + groupID := int64(25) + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + {ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5, AccountGroups: []AccountGroup{{GroupID: groupID}}}, + {ID: 99, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Priority: 0, Status: StatusActive, Schedulable: true, Concurrency: 5}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + cache := &mockGatewayCacheForPlatform{} + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{ + groupID: { + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{ + "claude-sonnet-4-5-20250929": {99}, + }, + }, + }, + } + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + + concurrencyCache := &mockConcurrencyCache{ + loadMap: map[int64]*AccountLoadInfo{ + 1: {AccountID: 1, LoadRate: 0}, + 99: {AccountID: 99, LoadRate: 0}, + }, + } + + svc := &GatewayService{ + accountRepo: repo, + groupRepo: groupRepo, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + result, err := svc.SelectAccountWithLoadAwareness(ctx, &groupID, "codex-unbound", "claude-sonnet-4-5-20250929", nil, "", int64(0)) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID) + }) + t.Run("负载批量失败且无法获取-兜底等待", func(t *testing.T) { repo := &mockAccountRepoForPlatform{ accounts: []Account{ diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index c65e828a0dc..adabbf7072c 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -491,9 +491,13 @@ type ForwardResult struct { RequestID string Usage ClaudeUsage Model string + // BillingModel is the model used for cost calculation when it differs from + // the client-facing model, such as OpenAI compatibility forwarding. + BillingModel string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. UpstreamModel string + ServiceTier *string Stream bool Duration time.Duration FirstTokenMs *int // 首字时间(流式请求) @@ -1377,7 +1381,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro filteredExcluded++ continue } - account, ok := accountByID[routingAccountID] + account, ok := s.resolveExplicitModelRoutingAccount(ctx, group, groupID, routingAccountID, accountByID) if !ok || !s.isAccountSchedulableForSelection(account) { if !ok { filteredMissing++ @@ -1386,7 +1390,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } continue } - if !s.isAccountAllowedForPlatform(account, platform, useMixed) { + if !s.isExplicitModelRoutingAccountAllowed(group, groupID, account, platform, useMixed) { filteredPlatform++ continue } @@ -1430,11 +1434,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if sessionHash != "" && stickyAccountID > 0 { if containsInt64(routingAccountIDs, stickyAccountID) && !isExcluded(stickyAccountID) { // 粘性账号在路由列表中,优先使用 - if stickyAccount, ok := accountByID[stickyAccountID]; ok { + if stickyAccount, ok := s.resolveExplicitModelRoutingAccount(ctx, group, groupID, stickyAccountID, accountByID); ok { var stickyCacheMissReason string gatePass := s.isAccountSchedulableForSelection(stickyAccount) && - s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) && + s.isExplicitModelRoutingAccountAllowed(group, groupID, stickyAccount, platform, useMixed) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, stickyAccount, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, stickyAccount, requestedModel) && s.isAccountSchedulableForQuota(stickyAccount) && @@ -2056,6 +2060,48 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform return account.Platform == platform } +func (s *GatewayService) isExplicitModelRoutingOpenAIOAuthAccount(group *Group, account *Account) bool { + return group != nil && group.Platform == PlatformAnthropic && account != nil && account.IsOpenAIOAuth() +} + +func (s *GatewayService) isExplicitModelRoutingAccountAllowed(group *Group, groupID *int64, account *Account, platform string, useMixed bool) bool { + if account == nil { + return false + } + if s.isExplicitModelRoutingOpenAIOAuthAccount(group, account) { + return s.isAccountInGroup(account, groupID) + } + return s.isAccountAllowedForPlatform(account, platform, useMixed) +} + +func (s *GatewayService) resolveExplicitModelRoutingAccount( + ctx context.Context, + group *Group, + groupID *int64, + accountID int64, + accountByID map[int64]*Account, +) (*Account, bool) { + if accountByID != nil { + if account, ok := accountByID[accountID]; ok && account != nil { + return account, true + } + } + if group == nil || group.Platform != PlatformAnthropic || accountID <= 0 { + return nil, false + } + account, err := s.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + return nil, false + } + if !account.IsOpenAIOAuth() || !s.isAccountInGroup(account, groupID) { + return nil, false + } + if accountByID != nil { + accountByID[accountID] = account + } + return account, true +} + func (s *GatewayService) isAccountSchedulableForSelection(account *Account) bool { if account == nil { return false @@ -2713,6 +2759,10 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if groupID != nil && s.groupRepo != nil { schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID) } + routingGroup := schedGroup + if routingGroup == nil && groupID != nil { + routingGroup, _ = s.resolveGroupByID(ctx, *groupID) + } var accounts []Account accountsLoaded := false @@ -2737,7 +2787,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, if clearSticky { _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } - if !clearSticky && s.isAccountInGroup(account, groupID) && account.Platform == platform && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { + if !clearSticky && s.isAccountInGroup(account, groupID) && s.isExplicitModelRoutingAccountAllowed(routingGroup, groupID, account, platform, false) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) { if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID) } @@ -2764,17 +2814,15 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, ctx = s.withWindowCostPrefetch(ctx, accounts) ctx = s.withRPMPrefetch(ctx, accounts) - routingSet := make(map[int64]struct{}, len(routingAccountIDs)) - for _, id := range routingAccountIDs { - if id > 0 { - routingSet[id] = struct{}{} - } + accountByID := make(map[int64]*Account, len(accounts)) + for i := range accounts { + accountByID[accounts[i].ID] = &accounts[i] } var selected *Account - for i := range accounts { - acc := &accounts[i] - if _, ok := routingSet[acc.ID]; !ok { + for _, routingAccountID := range routingAccountIDs { + acc, ok := s.resolveExplicitModelRoutingAccount(ctx, routingGroup, groupID, routingAccountID, accountByID) + if !ok { continue } if _, excluded := excludedIDs[acc.ID]; excluded { @@ -2791,6 +2839,9 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context, fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name)) continue } + if !s.isExplicitModelRoutingAccountAllowed(routingGroup, groupID, acc, platform, false) { + continue + } if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) { continue } @@ -2973,6 +3024,10 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g if groupID != nil && s.groupRepo != nil { schedGroup, _ = s.groupRepo.GetByID(ctx, *groupID) } + routingGroup := schedGroup + if routingGroup == nil && groupID != nil { + routingGroup, _ = s.resolveGroupByID(ctx, *groupID) + } var accounts []Account accountsLoaded := false @@ -2996,7 +3051,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g _ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash) } if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) { - if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) { + if s.isExplicitModelRoutingAccountAllowed(routingGroup, groupID, account, nativePlatform, true) { if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy mixed routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID) } @@ -3020,17 +3075,15 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g ctx = s.withWindowCostPrefetch(ctx, accounts) ctx = s.withRPMPrefetch(ctx, accounts) - routingSet := make(map[int64]struct{}, len(routingAccountIDs)) - for _, id := range routingAccountIDs { - if id > 0 { - routingSet[id] = struct{}{} - } + accountByID := make(map[int64]*Account, len(accounts)) + for i := range accounts { + accountByID[accounts[i].ID] = &accounts[i] } var selected *Account - for i := range accounts { - acc := &accounts[i] - if _, ok := routingSet[acc.ID]; !ok { + for _, routingAccountID := range routingAccountIDs { + acc, ok := s.resolveExplicitModelRoutingAccount(ctx, routingGroup, groupID, routingAccountID, accountByID) + if !ok { continue } if _, excluded := excludedIDs[acc.ID]; excluded { @@ -3047,8 +3100,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name)) continue } - // 过滤:原生平台直接通过,antigravity 需要启用混合调度 - if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() { + if !s.isExplicitModelRoutingAccountAllowed(routingGroup, groupID, acc, nativePlatform, true) { continue } if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) { @@ -7799,6 +7851,9 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 确定计费模型 billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) + if result.BillingModel != "" { + billingModel = strings.TrimSpace(result.BillingModel) + } if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { billingModel = input.ChannelMappedModel } @@ -7968,6 +8023,10 @@ func (s *GatewayService) calculateTokenCost( var cost *CostBreakdown var err error + serviceTier := "" + if result.ServiceTier != nil { + serviceTier = strings.TrimSpace(*result.ServiceTier) + } // 优先尝试渠道定价 → CalculateCostUnified if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil { @@ -7979,6 +8038,7 @@ func (s *GatewayService) calculateTokenCost( Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, + ServiceTier: serviceTier, Resolver: s.resolver, Resolved: resolved, }) @@ -7988,6 +8048,8 @@ func (s *GatewayService) calculateTokenCost( billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier, ) + } else if serviceTier != "" { + cost, err = s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, serviceTier) } else { cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) } @@ -8025,6 +8087,7 @@ func (s *GatewayService) buildRecordUsageLog( Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model), + ServiceTier: result.ServiceTier, ReasoningEffort: result.ReasoningEffort, InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 8ab08b733ea..cf6f6979efe 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1331,7 +1331,7 @@ :key="account.id" class="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300" > - {{ account.name }} + {{ formatModelRoutingAccountLabel(account) }}