From fd82dfd52d31babdceb2d20e0ef1126e508d0f8d Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 09:22:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20Grok=20=E9=95=BF=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E5=8F=AA=E8=B7=9F=E5=88=86=E7=BB=84=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=8F=97=20OpenAI=20=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=90=A6=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai_long_context_billing_enabled 是 OpenAI 账号设置,Grok 账号无法打开。按账号硬传 false 会让分组开关失效,官方 200k 阶梯和渠道多档区间全部塌到第一档。非 OpenAI 不再传账号门闩。 --- .../openai_alpha_search_billing_test.go | 6 +-- .../openai_gateway_record_usage_test.go | 52 +++++++++++++++++++ .../openai_gateway_search_surcharge_test.go | 6 +-- .../internal/service/openai_gateway_usage.go | 29 ++++++++--- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/backend/internal/service/openai_alpha_search_billing_test.go b/backend/internal/service/openai_alpha_search_billing_test.go index 7151725763..99cacc88f7 100644 --- a/backend/internal/service/openai_alpha_search_billing_test.go +++ b/backend/internal/service/openai_alpha_search_billing_test.go @@ -50,7 +50,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 即使 token 倍率(含高峰,3.0)更高也不采用。 apiKey := &APIKey{ID: 1, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformOpenAI}} result := &OpenAIForwardResult{Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", WebSearchCalls: 1} - cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", false) + cost, err := svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 3.0, 1.0, 1.0, 2.0, UsageTokens{}, "", boolPtr(false)) require.NoError(t, err) require.Equal(t, string(BillingModePerRequest), cost.BillingMode) require.InDelta(t, 0.01, cost.TotalCost, 1e-12) @@ -58,7 +58,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // 分组配置单价 0.005 apiKey.Group.WebSearchPricePerCall = float64Ptr(0.005) - cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", false) + cost, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{}, "", boolPtr(false)) require.NoError(t, err) require.InDelta(t, 0.005, cost.TotalCost, 1e-12) require.InDelta(t, 0.005, cost.ActualCost, 1e-12) @@ -66,7 +66,7 @@ func TestCalculateOpenAIRecordUsageCostWebSearchPerCall(t *testing.T) { // WebSearchCalls = 0 时不得走按次分支(无定价数据会返回 pricing 错误, // 证明回落到了 token 路径而不是被按次分支吞掉)。 result.WebSearchCalls = 0 - _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", false) + _, err = svc.calculateOpenAIRecordUsageCost(context.Background(), result, apiKey, []string{"gpt-5.6-sol"}, 1.0, 1.0, 1.0, 1.0, UsageTokens{InputTokens: 10}, "", boolPtr(false)) require.Error(t, err) } diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 97ce3d185d..e59b3b205a 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1210,6 +1210,58 @@ func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow }) } +// openai_long_context_billing_enabled is an OpenAI-only account setting, so it +// must not veto the official Grok >=200k ladder: a Grok account has no way to +// ever set that flag, which would make the group toggle unreachable. +func TestOpenAIGatewayServiceRecordUsage_GrokLongContextFollowsGroupToggleOnly(t *testing.T) { + baseInput := 250000 * 2e-6 + baseOutput := 1000 * 6e-6 + + grokAccount := func(id int64) *Account { + return &Account{ID: id, Platform: PlatformGrok, Type: AccountTypeOAuth} + } + + t.Run("group on applies the official ladder", func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_longctx_on", + Usage: OpenAIUsage{InputTokens: 250000, OutputTokens: 1000}, + Model: "grok-4.5", + Duration: time.Second, + }, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1030, true), + User: &User{ID: 2030}, + Account: grokAccount(3030), + }) + require.NoError(t, err) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*2, usageRepo.lastLog.OutputCost, 1e-10) + }) + + t.Run("group off keeps the base card", func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_grok_longctx_off", + Usage: OpenAIUsage{InputTokens: 250000, OutputTokens: 1000}, + Model: "grok-4.5", + Duration: time.Second, + }, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1031, false), + User: &User{ID: 2031}, + Account: grokAccount(3031), + }) + require.NoError(t, err) + require.False(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput, usageRepo.lastLog.OutputCost, 1e-10) + }) +} + func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSetting(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/openai_gateway_search_surcharge_test.go b/backend/internal/service/openai_gateway_search_surcharge_test.go index fd3eefe4b4..2633a5b930 100644 --- a/backend/internal/service/openai_gateway_search_surcharge_test.go +++ b/backend/internal/service/openai_gateway_search_surcharge_test.go @@ -36,7 +36,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchIsAdditiveToTokens(t *testing.T) { 1.0, UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", - false, + boolPtr(false), ) require.NoError(t, err) require.NotNil(t, cost) @@ -66,7 +66,7 @@ func TestCalculateOpenAIRecordUsageCost_SearchOnlyWhenNoTokenPricing(t *testing. 1.0, UsageTokens{}, "", - false, + boolPtr(false), ) require.NoError(t, err) require.NotNil(t, cost) @@ -111,7 +111,7 @@ func TestCalculateOpenAIRecordUsageCost_TokenPricingErrorNotSwallowedBySearch(t 1.0, UsageTokens{InputTokens: 1000, OutputTokens: 500}, "", - false, + boolPtr(false), ) require.Error(t, err) require.Nil(t, cost) diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 0b88439437..c55bcdbeac 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -211,7 +211,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec return err } } - longContextBillingEnabled := billingAccount.IsOpenAILongContextBillingEnabled() + longContextBillingGate := openAILongContextBillingGate(billingAccount) cost, err = s.calculateOpenAIRecordUsageCost( ctx, result, @@ -223,7 +223,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec baseMultiplier, tokens, serviceTier, - longContextBillingEnabled, + longContextBillingGate, ) if err != nil { if !isUsagePricingUnavailableError(err) { @@ -256,7 +256,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec responseModels := usageBillingModelCandidates(responseModel) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, - videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingEnabled, + videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, ) // 基线定价源以 baselineBillingModel 为准:它正是 calculateOpenAIRecordUsageCost // 内部做渠道定价判断时使用的模型,且"首候选有渠道价"必然意味着首候选就是实际 @@ -480,6 +480,19 @@ func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Co return s.billingService.HasIdentifiedTokenPricing(model), false } +// openAILongContextBillingGate returns the per-account long-context opt-in. +// The flag is an OpenAI-only account setting, so other platforms (Grok) return +// nil — "no per-account gate" — and are governed by the group toggle alone. +// Returning a hardcoded false for them would veto the official model ladders +// (e.g. the Grok >=200k 2x card) that no account setting can ever re-enable. +func openAILongContextBillingGate(account *Account) *bool { + if account == nil || !account.IsOpenAI() { + return nil + } + enabled := account.IsOpenAILongContextBillingEnabled() + return &enabled +} + func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, @@ -491,7 +504,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( webSearchMultiplier float64, tokens UsageTokens, serviceTier string, - longContextBillingEnabled bool, + longContextBillingGate *bool, ) (*CostBreakdown, error) { billingModel := firstUsageBillingModel(billingModels) if result != nil && result.WebSearchCalls > 0 { @@ -543,7 +556,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( multiplier, tokens, serviceTier, - longContextBillingEnabled, + longContextBillingGate, ) if err == nil { tokenCost = cost @@ -632,7 +645,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( multiplier float64, tokens UsageTokens, serviceTier string, - longContextBillingEnabled bool, + longContextBillingGate *bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID @@ -640,7 +653,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, ServiceTier: serviceTier, Resolver: s.resolver, - LongContextBillingEnabled: &longContextBillingEnabled, + LongContextBillingEnabled: longContextBillingGate, }) } return s.billingService.calculateCostWithServiceTierPolicy( @@ -648,7 +661,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( tokens, multiplier, serviceTier, - longContextBillingEnabled, + longContextBillingGate == nil || *longContextBillingGate, ) } From e29b93a1fbc6753590b0bbda010c3ab1ab3361c6 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 09:22:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E6=9C=AA=E7=9F=A5=20Grok=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=85=9C=E5=BA=95=E6=8E=92=E9=99=A4=20image/?= =?UTF-8?q?video/audio=20=E7=AD=89=E5=AA=92=E4=BD=93=E6=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带版本号的媒体 ID(grok-2-image-1212、grok-5-video)会被 grok-N* 白名单当成文本,错吃 4.5 token 价。vision 多模态 仍按 token 计;HasIdentifiedTokenPricing 也不放行这些 ID。 --- backend/internal/service/billing_service.go | 16 ++++++++++++++++ backend/internal/service/billing_service_test.go | 8 ++++++++ .../service/response_model_billing_test.go | 6 ++++++ 3 files changed, 30 insertions(+) diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index c13908443e..0356f698a0 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -862,6 +862,9 @@ func (s *BillingService) grokUnknownTextFamilyFallback(model string) *ModelPrici func isGrokUnknownTextFamilyModel(model string) bool { native := strings.ToLower(strings.TrimSpace(xai.StripGrokProviderPrefix(model))) + if isGrokMediaFamilyModel(native) { + return false + } switch { case native == "grok", native == "grok-latest": return true @@ -877,6 +880,19 @@ func isGrokUnknownTextFamilyModel(model string) bool { } } +// isGrokMediaFamilyModel matches ids that are billed per image/video/audio unit +// rather than per token, so version-numbered media ids (grok-2-image-1212, +// grok-5-video) cannot slip into the unknown-text fallback and pick up a token +// card. "vision" is deliberately absent: multimodal chat models are token billed. +func isGrokMediaFamilyModel(native string) bool { + for _, marker := range []string{"imagine", "image", "video", "audio", "speech", "tts", "transcribe", "realtime"} { + if strings.Contains(native, marker) { + return true + } + } + return false +} + // HasIdentifiedTokenPricing 判断模型能否在价格表中被"确定性识别"出 token 价格。 // // 与 GetModelPricing 的关键区别:本函数拒绝按子串猜系列的兜底。GetModelPricing 会 diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index d1ea67645c..80d07af44b 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -1221,6 +1221,14 @@ func TestGetModelPricing_UnknownGrokTextFallsBackToGrok45(t *testing.T) { require.InDelta(t, baseline.CacheReadPricePerToken, pricing.CacheReadPricePerToken, 1e-12, model) } + // Per-unit media ids must not inherit the text card just because they carry + // a version number; they are billed by the image/video/audio paths instead. + for _, model := range []string{"grok-2-image-1212", "grok-2-audio", "grok-5-video", "x-ai/grok-6-image"} { + require.False(t, isGrokUnknownTextFamilyModel(model), "model %s", model) + } + // Multimodal chat models stay token billed. + require.True(t, isGrokUnknownTextFamilyModel("grok-2-vision-1212")) + for _, model := range []string{ "grok-imagine-image-3.0", "grok-imagine-video-2", diff --git a/backend/internal/service/response_model_billing_test.go b/backend/internal/service/response_model_billing_test.go index 9194a1684e..6b04fc424a 100644 --- a/backend/internal/service/response_model_billing_test.go +++ b/backend/internal/service/response_model_billing_test.go @@ -372,6 +372,12 @@ func TestBillingServiceHasIdentifiedTokenPricing_RejectsFamilyGuesses(t *testing } require.False(t, billing.HasIdentifiedTokenPricing("")) require.False(t, billing.HasIdentifiedTokenPricing("zz-unpriced-response-model")) + // Versioned media ids may inherit a text card via GetModelPricing; the + // identified-token gate must still reject them so response-model billing + // cannot adopt grok-4.5 rates for image/audio/video ids. + require.False(t, billing.HasIdentifiedTokenPricing("grok-2-image-1212")) + require.False(t, billing.HasIdentifiedTokenPricing("grok-2-audio")) + require.False(t, billing.HasIdentifiedTokenPricing("grok-5-video")) } func TestGatewayServiceRecordUsage_ResponseModelRejectsUnidentifiedFamilyName(t *testing.T) { From e215c98c2c959e2b93700d59af08ebec189b27fe Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 09:22:41 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E8=B4=A6=E5=8F=B7=E9=A1=B5=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=B7=E6=96=B0=E5=81=8F=E5=A5=BD=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=88=9D=E5=A7=8B=E5=8C=96=E6=97=B6=E6=81=A2?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onMounted 再读一次会覆盖已在 setup 阶段恢复的偏好。 --- frontend/src/views/admin/AccountsView.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 3bfc9357ec..cfab5c07e3 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -2440,7 +2440,6 @@ const handleClickOutside = (event: MouseEvent) => { onMounted(async () => { if (typeof window !== 'undefined') { - loadSavedAutoRefresh() desktopViewportMediaQuery = window.matchMedia(desktopViewportQuery) isDesktopViewport.value = desktopViewportMediaQuery.matches desktopViewportListener = (event: MediaQueryListEvent) => {